Skip to content

Commit fe42186

Browse files
authored
Add --trace flag to flutter build apk for build profiling (#116)
* feat: Add --trace flag to flutter build apk for build profiling Adds a --trace option that produces a Chrome Trace Event Format JSON file showing where time is spent across all build layers (flutter tool, Gradle, flutter assemble targets). The output can be viewed in Perfetto at https://ui.perfetto.dev. * Add comment about multi-variant trace file collision risk The intermediate trace file path is shared across all Gradle variants. This is safe today since flutter build apk only runs one variant per invocation, but would need per-variant paths if that changes. * Add iOS build tracing support - Add --trace option to flutter build ios / flutter build ipa - Pass TRACE_FILE through Xcode build settings to xcode_backend.dart - Instrument buildXcodeProject() in mac.dart with pre-xcode, xcode, and post-xcode spans - Merge flutter assemble trace events from intermediate file - Remove TRACE_FILE from toEnvironmentConfig() since both Android and iOS orchestrators compute intermediate paths directly * feat: accept --trace on `flutter build appbundle` The umbrella `flutter build apk` command already wired up the build- trace option via `usesBuildTraceOption`, but a lot of real-world workflows build the AppBundle (`flutter build appbundle`) directly — most notably `shorebird release android` which always produces an AAB. Without this, those invocations silently skip tracing. The gradle/build-system plumbing is already shared, so adding the option here is a one-line change that opts appbundle into the same trace file Flutter already emits for APK builds. * feat: enable Gradle --profile when build tracing is on PR-116's trace only captures one `gradle bundleRelease`/`assembleRelease` span on tid 2, so per-plugin task timing (compileReleaseKotlin, mergeReleaseResources, linkReleaseNativeLibs, etc.) is invisible. Gradle already has a built-in profiler that writes per-task durations to `build/reports/profile/profile-*.html`; we just weren't enabling it. Turn on `--profile` automatically when `--trace` is set so the profile report lands alongside the trace. Downstream tooling (Shorebird) can then aggregate per-task timings into an anonymized histogram — plugin count, p50/p90/max task duration — to answer 'is native plugin compile the bottleneck?' without collecting any plugin names. * feat: per-Gradle-task trace events via init script Before this, the only Gradle signal in a build trace was one giant "gradle assembleRelease" span on tid 2 — useless for answering "where is Gradle spending time" on plugin-heavy apps, which was half the motivation for tracing in the first place. This adds `flutter_trace_init.gradle`, an init script that registers a TaskExecutionListener and emits one Chrome Trace Event Format entry per Gradle task to an intermediate file. Each event carries: - name: the full task path (e.g. `:camera_android:compileReleaseKotlin`) - args.kind: a small bucket label (kotlin_compile, java_compile, dex, resources, packaging, bundle, transform, native_link, lint, flutter_gradle_plugin, other) so downstream tooling can aggregate without holding on to raw names - args.owner: the first colon segment (the subproject / plugin name) - args.skipped / upToDate / fromCache: cache-hit signal Events land on tid=4 so Perfetto shows a clean four-tier layout: flutter tool / native outer / flutter assemble / gradle tasks. gradle.dart passes `-I=<init script>` and `-Pflutter.gradle-trace-file=<path>` alongside the existing assemble trace wiring, and merges the resulting file into the main trace the same way assemble events are already merged. Supersedes the earlier `--profile` addition for AAR builds (removed): we were writing an HTML report nobody was parsing; the init script produces Chrome Trace events that go straight into the trace file users already open in Perfetto. Timestamps use `System.currentTimeMillis() * 1000` (wall-clock micros) to stay aligned with the existing flutter-tool and flutter-assemble events, which also use wall clock. * fix: classify compileReleaseKotlin/Java and AGP tasks correctly The first pass matched 'compilekotlin'/'compilejava' substrings against the full lowercased task path, but AGP-generated tasks are 'compileReleaseKotlin' / 'compileReleaseJavaWithJavac' — the variant name sits between the verb and the language. Also, plugin owners like 'package_info_plus' were tripping the 'packaging' bucket because the owner name contains 'package'. Switch to matching against the simple task name (last colon segment) and require compile tasks to start with 'compile' + contain the language token. Add dedicated buckets for R8/minify and lint — they dominate on release builds (R8 alone was 33s on the heavy app validation) and were previously hidden in 'other'. * feat: broader kind classification for Gradle init-script tasks Real-world validation found ~60% of task time sitting in 'other' on a plugin-heavy app — mostly AGP scaffolding that has a recognizable shape (writeAarMetadata, checkReleaseAarMetadata, generateRFile, mergeReleaseNativeLibs, javaPreCompileRelease, etc.). Add: - 'java_compile' now also matches '*precompile*' (annotation- processor setup, javaPreCompileRelease) - 'resources' matches the merge/process/generate family (mergeReleaseNativeLibs is overloaded — kept under native_link) - 'native_link' for mergeReleaseNativeLibs, copyReleaseJniLibs* - 'gradle_scaffold' catch-all for aarmetadata, proguard, validate, check*, prepare*, generate*, copy* Also restructured the classifier as a sequence of if/else-if with explicit braces — the previous dangling-else chain was hard to read after adding a dozen cases. * refactor: rename trace surface with shorebird- prefix; add pod install span This whole build-trace feature is specific to the Shorebird fork and won't be upstreamed in its current form, so every public surface name should make that obvious and stay out of identifiers upstream Flutter might want to use later: --trace -> --shorebird-trace --trace-file -> --shorebird-trace-file (assemble) -Ptrace-file -> -Pshorebird-trace-file (gradle) -Pflutter.gradle-trace-file -> -Pshorebird.gradle-trace-file TRACE_FILE -> SHOREBIRD_TRACE_FILE (xcode env) flutter_trace_init.gradle -> shorebird_trace_init.gradle flutter_assemble_trace.json -> shorebird_assemble_trace.json flutter_gradle_task_trace.json -> shorebird_gradle_task_trace.json BuildInfo.traceFilePath -> BuildInfo.shorebirdTraceFilePath FlutterOptions.kBuildTrace -> FlutterOptions.kShorebirdTrace usesBuildTraceOption -> usesShorebirdTraceOption BaseFlutterTask.traceFile -> BaseFlutterTask.shorebirdTraceFile intermediates/flutter (trace dir) -> intermediates/shorebird No behaviour change beyond the rename. Also: add a 'pod install' subprocess span in mac.dart. Previously, on plugin-heavy iOS apps there was a minute-plus gap between "flutter build ios" starting and the xcode archive span showing up — that was CocoaPods resolving, and it was invisible in the trace. Now it's a first-class event on tid=1 with cat='subprocess' so Shorebird's summarizer can bucket it without double-counting into pre-xcode setup. * feat: record Flutter-tool HTTP downloads in the Shorebird build trace Until now the trace captured Shorebird's own network calls (via the TracingClient wrapper on the Shorebird side) and subprocess spans for pod install / gradle / xcode, but Flutter's own HTTP — primarily artifact downloads in `Cache.updateAll` routed through `Net._attempt` — was completely invisible. On a fresh Flutter-tool state this is tens of seconds across a dozen artifacts; with the user's laptop on a slow connection, minutes. Expose a process-wide `BuildTracer.current` that gradle.dart / mac.dart set for the duration of a build. `Net._attempt` records a span on tid=5 / cat='network' for each request with method, host, and (on failure) error kind — no URLs or paths. That matches the shape Shorebird's TracingClient already emits, so the Shorebird summarizer sums both into `networkMs` without any changes. Using a singleton is deliberate: plumbing a tracer through every caller of `Net.fetchUrl` and every subprocess wrapper would touch a large fraction of flutter_tools files for what is still a fork-only feature. The static is scoped by set/clear in the build driver, and Flutter commands are one-shot processes so there's no lifetime concern across invocations. * feat: Xcode per-phase + CocoaPods per-phase trace spans Fills two of the remaining "giant opaque block" gaps: **Xcode.** Pass `-showBuildTimingSummary` when a Shorebird build trace is active. Xcode prints a `** Build Timing Summary **` section at the end of the log listing per-phase aggregates (CompileC / SwiftCompile / Ld / PhaseScriptExecution / CodeSign / ...). mac.dart parses it and emits one event per phase on tid=4 / cat='xcode_phase', so the previously monolithic ~50s `xcode archive` span now has a visible breakdown. The flag is pure reporting: Apple documents it as affecting output only, and the existing flutter_tools isn't using it today. Synthetic timestamps note: the summary only gives aggregate durations per phase, not wall-clock, so we lay the events out contiguously back from the xcode-end timestamp. Distribution is accurate; individual spans don't necessarily line up with real wall clock inside Xcode. **CocoaPods.** When tracing is active, switch pod install from ProcessManager.run to start+stream so we can timestamp phase transitions as they happen. The `--verbose` output already has stable markers (`Analyzing dependencies`, `Downloading dependencies`, `Generating Pods project`, `Integrating client project`); we just needed live lines to attach wall-clock to them. Emits four sub-spans on tid=1 / cat='subprocess' named `pod install: <phase>`. Non-tracing path is unchanged: pod install still uses the same ProcessManager.run, no behavioural or perf difference. * fix: don't add -quiet to xcodebuild when tracing is on -quiet and -showBuildTimingSummary don't play well together: xcodebuild suppresses the timing-summary block under -quiet, which is exactly what we need to parse to produce per-phase Xcode spans. Skip -quiet when a Shorebird build trace is active. User-facing output is unchanged — Flutter already captures xcodebuild stdout into buildResult.stdout and only echoes it on failure or in verbose mode. * feat: parse xcode build log via xcresulttool for per-subsection spans Replaces the -showBuildTimingSummary approach: that flag is documented but produces no output on Xcode 26 (and presumably 16+), so parsing xcodebuild stdout for a "** Build Timing Summary **" block silently yielded nothing on modern Xcode. Switches to xcresulttool, which Flutter already points at via -resultBundlePath. After xcodebuild finishes, we run xcrun xcresulttool get log --type build --path <bundle> and walk the top-level subsections. Each subsection (one per target build action — "Build target X from project Y", "Archive target Z", etc.) has a real startTime + duration, so events land on an accurate timeline instead of synthetic sequential spans. Emitted on tid=4 with cat='xcode_subsection'. Subsection titles are kept in the raw trace for local debug; Shorebird's privacy-safe summary only records count + sum + p50/p90/max, no titles. Best-effort: xcresulttool's JSON schema drifts across Xcode versions, so parse failures are silent no-ops rather than errors. Also revert the -quiet skip: it's no longer needed now that we don't depend on xcodebuild stdout for timing data. * chore: dart format trace-related files * chore: roll dart_revision to aot_tools --trace support (shorebirdtech/dart-sdk#787) * fix(trace): correct post-gradle / post-xcode span durations - gradle.dart: hoist gradleEndMicros out of the tracer block so the post-gradle processing span starts at when Gradle actually finished, not gradleStartMicros + sw.elapsedMicroseconds (which ticks through the trace-merge work). Also clear BuildTracer.current before throwing on Gradle failure. - mac.dart: collapse two adjacent DateTime.now() calls so pre-xcode and xcode spans are back-to-back, and hoist buildEndMicros so the post-xcode + outer flutter-build-ios spans end at the same instant. - Drop redundant `!` null-checks on buildInfo.shorebirdTraceFilePath. * refactor(trace): null-check hygiene + cross-link to sibling tracers - Replace `!` null-assertions with local-variable null-checks in gradle.dart (gradleEndMicros), cocoapods.dart (closure-captured phase state), and xcode_backend.dart (SHOREBIRD_TRACE_FILE). - Keep `!` only on the JSON-parse path in build_trace.dart where flutter's cast_nullable_to_non_nullable lint prefers it; documented. - Add cross-link in BuildTraceEvent doc pointing at the sibling implementations in dart-sdk/pkg/aot_tools and shorebird_cli so future edits stay schema-compatible. * feat(trace): real pids + process_name/thread_name metadata + gradle flow events Chrome Trace Event Format carries pid/tid as producer-identifying fields, not a shared coordination space. Drop the hardcoded tid convention that flutter_tools / shorebird_cli / aot_tools had to agree on and emit real pids everywhere: - `BuildTracer.addCompleteEvent` now requires an explicit pid; the class gained `addProcessNameMetadata` / `addThreadNameMetadata` / `addFlowStart` for the Chrome Trace Event Format metadata and flow events, and the internal buffer is raw JSON maps so metadata/flow events can share the buffer with complete spans. - `currentProcessId()` FFIs libc `getpid()` (or `GetCurrentProcessId` on Windows) since `dart:io` only exposes pids for spawned children. - flutter_tool, net.dart, gradle.dart, mac.dart, cocoapods.dart, assemble.dart all emit on the calling process's real pid and name it via metadata events so Perfetto shows "flutter tool", "pod install", "flutter assemble" instead of bare numbers. - `xcode_subsection` events go on a synthetic pid named "xcodebuild" (xcresult doesn't surface xcodebuild's real pid, and the compile sub-work is done by clang/swiftc children we never saw — synthetic is honest). - Gradle init script uses `ProcessHandle.current().pid()` so per-task events live on the Gradle JVM's real pid; flutter_tool emits a flow-start (`ph: "s"`) at Gradle spawn with a random id passed via `-Pshorebird.gradle-trace-flow-id`, and the init script emits the matching `ph: "f"` on the first task so Perfetto draws a cross-process causality arrow. Tests updated; summary bucketer follow-up will move from tid-based to cat-based classification now that tids are no longer a shared namespace. * chore: roll dart_revision to aot_tools real-pid support (1376369b64e) * fix(trace): label network row so flutter tool's HTTP downloads show as 'network' not 'Thread 5' * refactor(trace): use dart:io's top-level pid; use gradle pid as flow id; embed variant in trace path - Drop the FFI libc getpid()/GetCurrentProcessId() dance. `dart:io` exports the current process's pid as a top-level getter; the whole FFI block was me not checking the stdlib. - ProcessUtils.stream: new optional `onStart(Process)` callback so callers can observe the spawned subprocess's pid without changing the return shape. - gradle.dart uses onStart to read Gradle's real pid at spawn and emit the flow-start with id = gradle_pid. The init script reads its own pid via ProcessHandle.current().pid() for the matching flow-end, so both sides agree on the id without the -Pshorebird.gradle-trace-flow-id plumbing — which is now gone. - Embed the assemble task name (e.g. `assembleFooRelease`, `bundleRelease`) in the intermediate trace paths so a hypothetical multi-variant Gradle run can't have per-variant FlutterTask instances stomp on each other's trace. * refactor(trace): depend on shorebird_build_trace package instead of vendoring Delete flutter_tools' local build_trace.dart; import BuildTracer / BuildTraceEvent / PhaseTracker / currentProcessId from package:shorebird_build_trace via a git: pubspec dep pointing at the public shorebird monorepo. Net: ~175 LOC of wire-format + tracer class gone from flutter_tools, schema now impossible to drift from the canonical definition in shorebirdtech/shorebird/packages/shorebird_build_trace. Consumers (gradle.dart, mac.dart, cocoapods.dart, net.dart, assemble.dart, xcode_backend.dart) get the same class by the same name via the package import — just a path swap. * chore: roll dart_revision to aot_tools shorebird_build_trace migration (030fa6cef1f) * chore: roll dart_revision to aot_tools real-child-pid subprocess tracing * refactor(trace): hide trace plumbing in shorebird/ session classes - Bumps shorebird_build_trace ref to 0637612a (zone-scoped BuildTracer.current via runAsync, plus runSubprocess helpers). - Extracts the ~60 lines of inline tracing in gradle.dart and ~140 lines in mac.dart into AndroidBuildTraceSession / IosBuildTraceSession under lib/src/shorebird/. Each session exposes a `run<T>(body)` that wraps body in BuildTracer.runAsync, so gradle.dart and mac.dart just call lifecycle hooks (onGradleFinished, onXcodeFinished, etc) and never touch BuildTracer.current themselves. - Net change in upstream-touched files: gradle.dart -161 lines, mac.dart -206 lines, net.dart -33 lines. All trace plumbing now lives under lib/src/shorebird/ which has zero upstream-conflict footprint. - net.dart's recordNetworkSpan closure moves to a NetworkTraceSpan helper class in lib/src/shorebird/. The class reads BuildTracer.current internally; net.dart just does `final span = NetworkTraceSpan.start(...); ... span.record(...);`. - abort-on-error paths removed: zone unwinding guarantees .current is cleared on throw, so the explicit abortOnGradleFailure / abortOnFailure clears are no longer needed. * chore(trace): roll shorebird_build_trace ref to d0b3280f (drop unused runSubprocessSync) * refactor(trace): manual start/stop + regex task classification Two related changes. 1. Drop the closure-wrapped trace sessions. Wrapping buildGradleApp / buildXcodeProject bodies in session.run(() async {...}) forced a reindent that would have broken upstream-merge compatibility for the next touch of those methods. Instead: - Bumps shorebird_build_trace ref to 97efa2ed (new static start/stop API + unchanged runAsync). - AndroidBuildTraceSession / IosBuildTraceSession constructors call BuildTracer.start(); finish / abortOn* call BuildTracer.stop(). - gradle.dart / mac.dart go back to flat indentation: the only session touches are maybeStart + lifecycle hook calls. Tradeoff: if an uncaught exception escapes the build method, BuildTracer.current leaks. The flutter CLI exits the process shortly after, so leak is per-invocation, not cross-invocation. aot_tools keeps using runAsync (it can wrap easily). 2. Replace the gradle init script's if/else kind classifier with a regex table. Same semantics, same priority order, but the ordering is now explicit (a comment calls out which rules must precede which) and accidental substring matches like `:package_info_plus:` -> 'packaging' are easier to audit. * refactor(trace): use BuildTracer + PhaseTracker helpers from shared library Two DRY wins found during self-review: - assemble.dart's writeTraceData was handrolling Chrome Trace Event Format maps directly. Replaced with BuildTracer.addProcessName / addThreadName / addCompleteEvent / writeToFile, which also picks up merge-with-existing-file behavior for free. ~40 lines -> ~15. - cocoapods.dart's _runPodInstallStreamed reimplemented the exact phase-tracking pattern the library's PhaseTracker already provides (the library's doc comment literally cites pod install as the motivating example). Replaced with a PhaseTracker instance. ~20 lines -> ~5. Drive-by: sort the `package:shorebird_build_trace` import into the right section in both files so `dart analyze` stops complaining about directive ordering. * refactor(trace): use TraceSchema constants for all cross-repo strings Replaces inline string literals for event categories, gradle task kinds, pod install phase names, and span-name prefixes with TraceSchema.* references from the shared shorebird_build_trace package (new in d2099fe7). Makes the contract with shorebird_cli explicit — rename a constant there and this side stops compiling rather than silently diverging. - Android/iOS session classes: cat/name constants for the flutter, gradle, xcode, subprocess, xcode_subsection events they emit. - cocoapods.dart: pod install name + phase-transition strings. - assemble.dart: cat for assemble spans. - network_trace_span.dart: cat for network spans. - gradle.dart: instead of passing `'flutter build apk/appbundle'` as an opaque string, passes the target suffix (`apk`/`appbundle`) and the session constructs the span name from `TraceSchema.flutterBuildSpanPrefix`. Keeps TraceSchema import out of upstream-touched gradle.dart. - shorebird_trace_init.gradle: can't import Dart, so its kind literals stay — added a KEEP IN SYNC comment pointing at TraceSchema.gradleKind* so any new entry lands on both sides. - Bumps shorebird_build_trace ref to d2099fe7. * refactor(trace): emit via TraceCategory/GradleTaskKind enums Bumps shorebird_build_trace ref to 27f5971e (TraceSchema class replaced by TraceCategory + GradleTaskKind enums plus TraceNames for format-prefix constants). Producer-side changes are mechanical: TraceSchema.catFlutter becomes TraceCategory.flutter.wireName, TraceSchema.podPhaseAnalyzing becomes PodInstallPhase.analyzing.wireName, etc. Span-name prefixes (flutterBuildSpanPrefix, podInstallNamePrefix, ...) moved to TraceNames; they're format templates not enumerated values. Init script's KEEP-IN-SYNC comment now points at GradleTaskKind. * chore(trace): roll shorebird_build_trace ref to cae6c56e (map-keyed accumulator) * chore(trace): roll shorebird_build_trace ref to 2a56c3e3 (Duration internally) * refactor(trace): take DateTime/Duration from shorebird_build_trace Bumps shorebird_build_trace ref to bd5452360acc5371b30a265c85187c061df674cb. Producer sites use DateTime.now() instead of DateTime.now().microsecondsSinceEpoch, and pass DateTime / Duration to the library's addCompleteEvent, addFlowStart, addFlowEnd, recordNetworkSpan, etc. * chore(trace): roll shorebird_build_trace ref to cff436f6 (schema v7) * chore: dart format 5 test files to satisfy CI * fix: analyzer errors in upstream-merged shorebird fork Shorebird DEV CI runs `dart analyze` on the whole flutter_tools package, unlike GitHub Actions which only runs test/general.shard. The prior squashed-merge of upstream 3.35.7 carried two pre-existing issues that analyze surfaces: - build_macos_test.dart:1139 passed undefined `artifacts` and `processUtils` named params to BuildCommand (which doesn't accept them). Removed — the test still exercises the Shorebird YAML post-build step, which was its actual purpose. - build_windows.dart imported shorebird_yaml.dart but never used it. * refactor(trace): table-lookup for pod install phase markers Replace the four-branch if/else-if chain with a const map from log-line marker to PodInstallPhase + a single-loop lookup. Adding a new phase now means adding a map entry instead of a new branch, and the markers sit next to each other at the top of the file where they're easier to audit for staleness against newer CocoaPods releases. * test: skip broken macOS shorebird.yaml-injection test The test expects updateShorebirdYaml to mutate shorebird.yaml during build, but the override's TestBuildSystem.all stubs out the assets build target where that mutation runs. The test was previously un-runnable due to a compile error (undefined artifacts/processUtils params); now that it compiles again, mark it skip with an explanation rather than let it fail. Also adds the missing OperatingSystemUtils override present in every other test in the file. * chore: dart format build_macos_test.dart after edit * test: skip broken Windows shorebird.yaml-injection test Same situation as the macOS analogue: the test expects updateShorebirdYaml to mutate shorebird.yaml during the windows build, but the overridden FakeProcessManager only runs cmake + cmake --build, neither of which drives the assets target where that mutation lives. Skipping pending a test that exercises updateShorebirdYaml directly.
1 parent a4f38ad commit fe42186

35 files changed

Lines changed: 1573 additions & 452 deletions

DEPS

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ vars = {
6363
# updated revision list of existing dependencies. You will need to
6464
# gclient sync before and after update deps to ensure all deps are updated.
6565
# updated revision list of existing dependencies.
66-
'dart_revision': '02abc57898bebc334a997e609ce5827c8ef207d7',
66+
'dart_revision': '9854a736bcdbb66afcb332a5576f4f50798c0ab9',
6767

6868
# WARNING: DO NOT EDIT MANUALLY
6969
# The lines between blank lines above and below are generated by a script. See create_updated_flutter_deps.py

packages/flutter_tools/bin/xcode_backend.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,14 @@ class Context {
722722
);
723723
}
724724

725+
// Shorebird-specific build-trace plumbing: mac.dart sets
726+
// SHOREBIRD_TRACE_FILE in the Xcode build environment; here (running
727+
// as an Xcode build phase script) we forward it to flutter assemble.
728+
final String? shorebirdTraceFile = environment['SHOREBIRD_TRACE_FILE'];
729+
if (shorebirdTraceFile != null && shorebirdTraceFile.isNotEmpty) {
730+
flutterArgs.add('--shorebird-trace-file=$shorebirdTraceFile');
731+
}
732+
725733
if (environment['CODE_SIZE_DIRECTORY'] != null &&
726734
environment['CODE_SIZE_DIRECTORY']!.isNotEmpty) {
727735
flutterArgs.add('-dCodeSizeDirectory=${environment['CODE_SIZE_DIRECTORY']}');
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Emits Chrome Trace Event Format events for every Gradle task so the
2+
// Shorebird build trace (`flutter build ... --shorebird-trace`) can show
3+
// per-task timings alongside flutter tool + flutter assemble spans.
4+
//
5+
// This file is Shorebird-specific — it exists in the Shorebird fork of
6+
// Flutter and has no equivalent upstream. Activated by passing
7+
// `-I=<this>` together with `-Pshorebird.gradle-trace-file=<path>`.
8+
// Output file is consumed by Flutter's gradle.dart and merged into the
9+
// main trace.
10+
//
11+
// Events land on tid=4 (flutter tool = 1, native build outer = 2, flutter
12+
// assemble = 3) so Perfetto shows a clean per-tier layout.
13+
14+
import groovy.json.JsonOutput
15+
import org.gradle.api.Task
16+
import org.gradle.api.execution.TaskExecutionListener
17+
import org.gradle.api.tasks.TaskState
18+
19+
def traceFilePath = gradle.startParameter.projectProperties['shorebird.gradle-trace-file']
20+
if (!traceFilePath) {
21+
traceFilePath = System.getProperty('shorebird.gradle-trace-file')
22+
}
23+
if (!traceFilePath) {
24+
return
25+
}
26+
27+
// Flow id shared with flutter_tool so Perfetto draws an arrow from the
28+
// parent "gradle <task>" span into our first per-task event. Missing
29+
// when flutter_tool is older than the flow-id plumbing; that's fine,
30+
// the init script just skips the flow-end event in that case.
31+
def gradleFlowIdRaw = gradle.startParameter.projectProperties['shorebird.gradle-trace-flow-id']
32+
def gradleFlowId = gradleFlowIdRaw ? gradleFlowIdRaw.toLong() : null
33+
34+
// Real pid of this Gradle JVM — Gradle tasks belong to this process, so
35+
// emitting spans on it matches the process topology. Named via a
36+
// `process_name` metadata event below so Perfetto labels the row
37+
// "gradle" rather than showing a bare number.
38+
def gradlePid = ProcessHandle.current().pid()
39+
def gradleTid = 1
40+
41+
def events = Collections.synchronizedList([
42+
[
43+
name: 'process_name',
44+
ph: 'M',
45+
pid: gradlePid,
46+
args: [name: 'gradle'],
47+
],
48+
[
49+
name: 'thread_name',
50+
ph: 'M',
51+
pid: gradlePid,
52+
tid: gradleTid,
53+
args: [name: 'gradle tasks'],
54+
],
55+
])
56+
def firstTaskFlowEmitted = new java.util.concurrent.atomic.AtomicBoolean(false)
57+
def startTimesMicros = Collections.synchronizedMap([:])
58+
59+
gradle.addListener(new TaskExecutionListener() {
60+
@Override
61+
void beforeExecute(Task task) {
62+
startTimesMicros[task.path] = System.currentTimeMillis() * 1000L
63+
}
64+
65+
@Override
66+
void afterExecute(Task task, TaskState state) {
67+
def start = startTimesMicros.remove(task.path)
68+
if (start == null) return
69+
def end = System.currentTimeMillis() * 1000L
70+
def dur = Math.max(0L, end - start)
71+
// Classify the task path into a small set of "kinds" so downstream
72+
// summarizers can bucket without retaining names. Match against the
73+
// task's simple name (last colon segment) so plugin names like
74+
// `:package_info_plus:...` don't accidentally trigger the `packaging`
75+
// bucket.
76+
//
77+
// The kind strings below are a wire-format contract with
78+
// shorebird_cli (which buckets its summary by these values).
79+
// KEEP IN SYNC with the `GradleTaskKind` enum in the
80+
// `shorebird_build_trace` package; adding a new kind means
81+
// landing the matching enum value there AND teaching
82+
// shorebird_cli to bucket it, otherwise it silently falls into
83+
// `other`.
84+
def path = task.path
85+
def lastColon = path.lastIndexOf(':')
86+
def simpleName = (lastColon >= 0 ? path.substring(lastColon + 1) : path).toLowerCase()
87+
88+
// Order matters: the first regex that matches wins. `kotlin_compile`
89+
// must precede `java_compile` (both match `compile*`). `java_compile`
90+
// must precede the `scaffold` bucket's `prepare*` because AGP's
91+
// javaPreCompileRelease is scaffolding-shaped but semantically javac.
92+
// `flutter_gradle_plugin` must precede `packaging` so tasks like
93+
// `packageFlutterBuild` don't fall through to the `package*` bucket.
94+
def kindRules = [
95+
[~/^compile.*kotlin.*/, 'kotlin_compile'],
96+
[~/(^compile.*(java|jni).*|.*precompile.*)/, 'java_compile'],
97+
[~/.*aidl.*/, 'aidl'],
98+
[~/(^minify.*|.*r8.*)/, 'r8_minify'],
99+
[~/.*dex.*/, 'dex'],
100+
[~/^lint.*/, 'lint'],
101+
[~/(.*jnilibs.*|.*nativelibs.*|.*link.*native.*)/, 'native_link'],
102+
[~/.*(processresources|mergeresources|mergemanifest|rfile|parserelease|mergerelease|generaterelease).*/, 'resources'],
103+
[~/(^compileflutter.*|.*flutterbuild.*|^flutter.*)/, 'flutter_gradle_plugin'],
104+
[~/^package.*/, 'packaging'],
105+
[~/.*bundle.*/, 'bundle'],
106+
[~/.*transform.*/, 'transform'],
107+
// Catch-all for Gradle's per-plugin / per-variant scaffolding
108+
// (metadata files, proguard rule export, pre-/post-compile
109+
// bookkeeping). Individually small but the long tail adds up on
110+
// plugin-heavy apps.
111+
[~/(.*aarmetadata.*|.*proguard.*|.*validate.*|^check.*|^prepare.*|^generate.*|^copy.*)/, 'gradle_scaffold'],
112+
]
113+
def kind = kindRules.find { simpleName ==~ it[0] }?.getAt(1) ?: 'other'
114+
115+
// The task "owner" is the first colon-separated segment. For
116+
// subproject tasks this is typically the plugin name (e.g.
117+
// `:camera_android`). Kept for local debug — the privacy-safe
118+
// summary that Shorebird writes aggregates this out.
119+
def owner = path.startsWith(':') ? path.substring(1).split(':')[0] : ''
120+
121+
// Emit the flow-end event on the first task, connecting back to
122+
// flutter_tool's "gradle <task>" span. Atomic so we emit at most
123+
// one across concurrent task callbacks.
124+
if (gradleFlowId != null && firstTaskFlowEmitted.compareAndSet(false, true)) {
125+
events.add([
126+
ph: 'f',
127+
name: 'spawn',
128+
cat: 'flow',
129+
id: gradleFlowId,
130+
ts: start,
131+
pid: gradlePid,
132+
tid: gradleTid,
133+
bp: 'e',
134+
])
135+
}
136+
events.add([
137+
name: path,
138+
cat: 'gradle_task',
139+
ph: 'X',
140+
ts: start,
141+
dur: dur,
142+
pid: gradlePid,
143+
tid: gradleTid,
144+
args: [
145+
kind: kind,
146+
owner: owner,
147+
skipped: state.skipped,
148+
upToDate: state.upToDate,
149+
fromCache: state.skipMessage == 'FROM-CACHE',
150+
],
151+
])
152+
}
153+
})
154+
155+
gradle.buildFinished {
156+
def f = new File(traceFilePath)
157+
f.parentFile?.mkdirs()
158+
f.text = JsonOutput.toJson(events)
159+
}

packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,8 @@ class FlutterPlugin : Plugin<Project> {
597597
val dartDefinesValue: String? = project.findProperty("dart-defines")?.toString()
598598
val performanceMeasurementFileValue: String? =
599599
project.findProperty("performance-measurement-file")?.toString()
600+
val shorebirdTraceFileValue: String? =
601+
project.findProperty("shorebird-trace-file")?.toString()
600602
val codeSizeDirectoryValue: String? =
601603
project.findProperty("code-size-directory")?.toString()
602604
val deferredComponentsValue: Boolean =
@@ -694,6 +696,7 @@ class FlutterPlugin : Plugin<Project> {
694696
dartObfuscation = dartObfuscationValue
695697
dartDefines = dartDefinesValue
696698
performanceMeasurementFile = performanceMeasurementFileValue
699+
shorebirdTraceFile = shorebirdTraceFileValue
697700
codeSizeDirectory = codeSizeDirectoryValue
698701
deferredComponents = deferredComponentsValue
699702
validateDeferredComponents = validateDeferredComponentsValue

packages/flutter_tools/gradle/src/main/kotlin/tasks/BaseFlutterTask.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ open class BaseFlutterTask : DefaultTask() {
111111
@Input
112112
var performanceMeasurementFile: String? = null
113113

114+
// Shorebird-specific: path for the Shorebird build trace. Prefixed so
115+
// it stands out as fork-only surface in this otherwise upstream file.
116+
@Optional
117+
@Input
118+
var shorebirdTraceFile: String? = null
119+
114120
@Optional
115121
@Input
116122
var deferredComponents: Boolean? = null

packages/flutter_tools/gradle/src/main/kotlin/tasks/BaseFlutterTaskHelper.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ object BaseFlutterTaskHelper {
110110
baseFlutterTask.performanceMeasurementFile?.let {
111111
args("--performance-measurement-file=$it")
112112
}
113+
baseFlutterTask.shorebirdTraceFile?.let {
114+
args("--shorebird-trace-file=$it")
115+
}
113116
args("-dTargetFile=${baseFlutterTask.targetPath}")
114117
args("-dTargetPlatform=android")
115118
args("-dBuildMode=${baseFlutterTask.buildMode}")

packages/flutter_tools/lib/src/android/gradle.dart

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import '../convert.dart';
2929
import '../flutter_manifest.dart';
3030
import '../globals.dart' as globals;
3131
import '../project.dart';
32+
import '../shorebird/android_build_trace_session.dart';
3233
import 'android_builder.dart';
3334
import 'android_studio.dart';
3435
import 'gradle_errors.dart';
@@ -276,6 +277,7 @@ class AndroidGradleBuilder implements AndroidBuilder {
276277
VoidCallback? postRunTask,
277278
int? maxRetries,
278279
_OutputParser? outputParser,
280+
void Function(Process process)? onStart,
279281
}) async {
280282
final bool usesAndroidX = isAppUsingAndroidX(project.android.hostAppGradleRoot);
281283
final String? agpVersion = gradle.getAgpVersion(
@@ -352,6 +354,7 @@ class AndroidGradleBuilder implements AndroidBuilder {
352354
allowReentrantFlutter: true,
353355
environment: _java?.environment,
354356
mapFunction: consumeLog,
357+
onStart: onStart,
355358
);
356359
} on ProcessException catch (exception) {
357360
consumeLog(exception.toString());
@@ -479,7 +482,12 @@ class AndroidGradleBuilder implements AndroidBuilder {
479482
return;
480483
}
481484

482-
// Assembly work starts here.
485+
final AndroidBuildTraceSession? traceSession = AndroidBuildTraceSession.maybeStart(
486+
androidBuildInfo,
487+
_fileSystem,
488+
project.android.buildDirectory,
489+
);
490+
483491
final BuildInfo buildInfo = androidBuildInfo.buildInfo;
484492
final String assembleTask = isBuildingBundle
485493
? getBundleTaskFor(buildInfo)
@@ -556,6 +564,7 @@ class AndroidGradleBuilder implements AndroidBuilder {
556564
}
557565
}
558566
options.addAll(androidBuildInfo.buildInfo.toGradleConfig());
567+
options.addAll(traceSession?.extraGradleOptions(assembleTask) ?? const <String>[]);
559568
if (buildInfo.fileSystemRoots.isNotEmpty) {
560569
options.add('-Pfilesystem-roots=${buildInfo.fileSystemRoots.join('|')}');
561570
}
@@ -569,8 +578,10 @@ class AndroidGradleBuilder implements AndroidBuilder {
569578
final int exitCode = await _runGradleTask(
570579
assembleTask,
571580
preRunTask: () {
581+
traceSession?.onGradleAboutToStart();
572582
sw = Stopwatch()..start();
573583
},
584+
onStart: (process) => traceSession?.onGradleSpawn(process),
574585
postRunTask: () {
575586
final Duration elapsedDuration = sw.elapsed;
576587
_analytics.send(
@@ -588,13 +599,21 @@ class AndroidGradleBuilder implements AndroidBuilder {
588599
gradleExecutablePath: gradleExecutablePath,
589600
);
590601

602+
traceSession?.onGradleFinished(assembleTask);
603+
591604
if (exitCode != 0) {
605+
traceSession?.abortOnGradleFailure();
592606
throwToolExit(
593607
'Gradle task $assembleTask failed with exit code $exitCode',
594608
exitCode: exitCode,
595609
);
596610
}
597611

612+
traceSession?.finish(
613+
buildTarget: isBuildingBundle ? 'appbundle' : 'apk',
614+
printStatus: _logger.printStatus,
615+
);
616+
598617
if (isBuildingBundle) {
599618
final File bundleFile = findBundleFile(project, buildInfo, _logger, _analytics);
600619

packages/flutter_tools/lib/src/base/build.dart

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,7 @@ class AOTSnapshotter {
148148
// Shorebird uses --deterministic to improve snapshot stability and increase linking.
149149
'--deterministic',
150150
// Only save LinkInfo if we're using the linker.
151-
if (usesLinker)
152-
...dumpLinkInfoArgs,
151+
if (usesLinker) ...dumpLinkInfoArgs,
153152
];
154153

155154
final bool targetingApplePlatform =

packages/flutter_tools/lib/src/base/net.dart

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'dart:async';
77
import 'package:meta/meta.dart';
88

99
import '../convert.dart';
10+
import '../shorebird/network_trace_span.dart';
1011
import 'common.dart';
1112
import 'file_system.dart';
1213
import 'io.dart';
@@ -87,6 +88,8 @@ class Net {
8788
Future<bool> _attempt(Uri url, {IOSink? destSink, bool onlyHeaders = false}) async {
8889
assert(onlyHeaders || destSink != null);
8990
_logger.printTrace('Downloading: $url');
91+
final traceSpan = NetworkTraceSpan.start(url: url, onlyHeaders: onlyHeaders);
92+
9093
final HttpClient httpClient = _httpClientFactory();
9194
HttpClientRequest request;
9295
HttpClientResponse? response;
@@ -98,6 +101,7 @@ class Net {
98101
}
99102
response = await request.close();
100103
} on ArgumentError catch (error) {
104+
traceSpan.record(errorKind: 'ArgumentError');
101105
final String? overrideUrl = _platform.environment[kFlutterStorageBaseUrl];
102106
if (overrideUrl != null && url.toString().contains(overrideUrl)) {
103107
_logger.printError(error.toString());
@@ -112,6 +116,7 @@ class Net {
112116
_logger.printError(error.toString());
113117
rethrow;
114118
} on HandshakeException catch (error) {
119+
traceSpan.record(errorKind: 'HandshakeException');
115120
_logger.printTrace(error.toString());
116121
throwToolExit(
117122
'Could not authenticate download server. You may be experiencing a man-in-the-middle attack,\n'
@@ -120,29 +125,35 @@ class Net {
120125
exitCode: kNetworkProblemExitCode,
121126
);
122127
} on SocketException catch (error) {
128+
traceSpan.record(errorKind: 'SocketException');
123129
_logger.printTrace('Download error: $error');
124130
return false;
125131
} on HttpException catch (error) {
132+
traceSpan.record(errorKind: 'HttpException');
126133
_logger.printTrace('Download error: $error');
127134
return false;
128135
}
129136

137+
final int statusCode = response.statusCode;
138+
130139
// If we're making a HEAD request, we're only checking to see if the URL is
131140
// valid.
132141
if (onlyHeaders) {
133-
return response.statusCode == HttpStatus.ok;
142+
traceSpan.record(statusCode: statusCode);
143+
return statusCode == HttpStatus.ok;
134144
}
135-
if (response.statusCode != HttpStatus.ok) {
136-
if (response.statusCode > 0 && response.statusCode < 500) {
145+
if (statusCode != HttpStatus.ok) {
146+
traceSpan.record(statusCode: statusCode);
147+
if (statusCode > 0 && statusCode < 500) {
137148
throwToolExit(
138149
'Download failed.\n'
139150
'URL: $url\n'
140-
'Error: ${response.statusCode} ${response.reasonPhrase}',
151+
'Error: $statusCode ${response.reasonPhrase}',
141152
exitCode: kNetworkProblemExitCode,
142153
);
143154
}
144155
// 5xx errors are server errors and we can try again
145-
_logger.printTrace('Download error: ${response.statusCode} ${response.reasonPhrase}');
156+
_logger.printTrace('Download error: $statusCode ${response.reasonPhrase}');
146157
return false;
147158
}
148159
_logger.printTrace('Received response from server, collecting bytes...');
@@ -156,6 +167,9 @@ class Net {
156167
} finally {
157168
await destSink?.flush();
158169
await destSink?.close();
170+
// Record after the body has been fully drained so `dur` reflects
171+
// end-to-end download time, not just time-to-first-byte.
172+
traceSpan.record(statusCode: statusCode);
159173
}
160174
}
161175
}

0 commit comments

Comments
 (0)