feat(cli): capture build trace + privacy-safe summary on release (#3692)

Co-authored-by: Brandon DeRosier <brandon@shorebird.dev>
This commit is contained in:
Eric Seidel
2026-04-20 11:24:22 -07:00
committed by GitHub
parent 065dd4bc35
commit 27a683e77b
46 changed files with 5346 additions and 98 deletions
+1
View File
@@ -15,3 +15,4 @@ bin/cache/
# Misc # Misc
.DS_Store .DS_Store
.claude/
+1 -1
View File
@@ -1 +1 @@
c48bfb11b86ed1d1215886f18d87d6fcd01df2e7 3b10eecea184bb381f1045a878eeff36548ed11e
+13
View File
@@ -7,6 +7,7 @@ words:
- aapt - aapt
- aarch - aarch
- aars # Android Archive - aars # Android Archive
- aidl # Android AIDL interface definition files
- allprojects # From gradle files - allprojects # From gradle files
- altool - altool
- ANDROIDD - ANDROIDD
@@ -20,17 +21,21 @@ words:
- bdero - bdero
- bintools - bintools
- bitcode - bitcode
- bitrise # Bitrise CI platform
- bryanoltman - bryanoltman
- bundletool - bundletool
- Dalvik # Android Dalvik VM / DEX file format - Dalvik # Android Dalvik VM / DEX file format
- canvaskit - canvaskit
- carryforward - carryforward
- ccache
- cipd - cipd
- codegen
- codemagic - codemagic
- codesign - codesign
- codesigned - codesigned
- codesigning - codesigning
- deflavored - deflavored
- develocity
- devicectl - devicectl
- dorny # From .github dir, doesn't show up in "**" check? - dorny # From .github dir, doesn't show up in "**" check?
- dyld - dyld
@@ -48,6 +53,7 @@ words:
- hotreload - hotreload
- idevicesyslog - idevicesyslog
- incrbyfloat # From ./packages/redis_client - incrbyfloat # From ./packages/redis_client
- ints
- iokit - iokit
- iphoneos - iphoneos
- keyalg # From .github/workflows/e2e.yaml - keyalg # From .github/workflows/e2e.yaml
@@ -82,6 +88,7 @@ words:
- parseable - parseable
- patchability - patchability
- pana - pana
- Perfetto # Chrome trace viewer at ui.perfetto.dev
- pkcs - pkcs
- podfile - podfile
- podspec - podspec
@@ -89,6 +96,7 @@ words:
- precache - precache
- previewable - previewable
- PRNG - PRNG
- proguard # Android ProGuard shrinker/obfuscator
- propertylistserialization - propertylistserialization
- propertylistserialization - propertylistserialization
- protos # Protocol buffer generated files - protos # Protocol buffer generated files
@@ -98,8 +106,10 @@ words:
- rdata - rdata
- reactivecircus # From .github dir, doesn't show up in "**" check? - reactivecircus # From .github dir, doesn't show up in "**" check?
- readlink - readlink
- reimplementation
- reinit - reinit
- requirepass # From .github dir, doesn't show up in "**" check? - requirepass # From .github dir, doesn't show up in "**" check?
- spawnee
- Retryable - Retryable
- RSAPKCS - RSAPKCS
- sdcard # From .github/workflows/e2e.yaml - sdcard # From .github/workflows/e2e.yaml
@@ -120,6 +130,7 @@ words:
- sysroot - sysroot
- tdigest - tdigest
- temurin # From .github dir, doesn't show up in "**" check? - temurin # From .github dir, doesn't show up in "**" check?
- timebase # Mach timebase (mach_timebase_info)
- udevadm # From .github/workflows/e2e.yaml - udevadm # From .github/workflows/e2e.yaml
- udid # Unique Device Identifier - udid # Unique Device Identifier
- unawaited - unawaited
@@ -142,6 +153,8 @@ words:
- xcframework - xcframework
- xcodebuild - xcodebuild
- xcodeproj - xcodeproj
- xcresult
- xcresulttool
- xcrun - xcrun
- xcscheme - xcscheme
- xcschemes - xcschemes
@@ -0,0 +1 @@
include: package:very_good_analysis/analysis_options.yaml
@@ -0,0 +1,23 @@
/// Chrome Trace Event Format producer for Shorebird's build-trace
/// plumbing. Used by `flutter_tools`, `dart-sdk/pkg/aot_tools`, and
/// `shorebird_cli` to emit a shared-format trace that opens in
/// https://ui.perfetto.dev.
///
/// Format doc: https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU
///
/// The goal is one wire format + one set of tracing helpers across the
/// three codebases that contribute events. Each consumer configures a
/// single [BuildTracer] and emits via the provided helpers
/// ([BuildTracer.trace], [BuildTracer.traceAsync],
/// [BuildTracer.timeSubprocess], [BuildTracer.timeSubprocessAsync],
/// [BuildTracer.recordNetworkSpan], [PhaseTracker]); this library owns
/// the JSON shape, the metadata/flow event types, and merging with
/// existing trace files.
library;
export 'src/build_trace_event.dart';
export 'src/build_tracer.dart';
export 'src/phase_tracker.dart';
export 'src/process_id.dart';
export 'src/run_subprocess.dart';
export 'src/trace_schema.dart';
@@ -0,0 +1,67 @@
/// A single event in a Chrome Trace Event Format trace.
///
/// Format doc: https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU
class BuildTraceEvent {
/// Creates a complete (`ph: "X"`) span.
BuildTraceEvent({
required this.name,
required this.cat,
required this.start,
required this.duration,
required this.pid,
required this.tid,
this.args,
});
// `!` is the lint-preferred pattern for required JSON fields: the trace
// format guarantees these, and the assertion fails loudly rather than
// silently coercing null through `as int`.
/// Parses a single event from its JSON representation.
factory BuildTraceEvent.fromJson(Map<String, Object?> json) {
return BuildTraceEvent(
name: json['name']! as String,
cat: json['cat']! as String,
start: DateTime.fromMicrosecondsSinceEpoch(json['ts']! as int),
duration: Duration(microseconds: json['dur']! as int),
pid: json['pid']! as int,
tid: json['tid']! as int,
args: json['args'] as Map<String, Object?>?,
);
}
/// The span name displayed in Perfetto.
final String name;
/// Event category (Perfetto filter / color).
final String cat;
/// Wall-clock start of the span (matches Perfetto's clock; serialized
/// as microseconds since epoch on the `ts` wire field).
final DateTime start;
/// Duration of the span (serialized as microseconds on the `dur` wire
/// field).
final Duration duration;
/// OS process id of the process that produced the event.
final int pid;
/// Thread id within [pid]. Logical row in Perfetto for the producer;
/// need not correspond to an OS thread.
final int tid;
/// Freeform metadata shown in the Perfetto span details pane.
final Map<String, Object?>? args;
/// JSON form of the event.
Map<String, Object?> toJson() => <String, Object?>{
'ph': 'X',
'name': name,
'cat': cat,
'ts': start.microsecondsSinceEpoch,
'dur': duration.inMicroseconds,
'pid': pid,
'tid': tid,
if (args != null) 'args': args,
};
}
@@ -0,0 +1,454 @@
import 'dart:convert';
import 'dart:io';
import 'package:shorebird_build_trace/src/build_trace_event.dart';
/// Shorebird convention: all events from one producer share a single pid
/// (the OS pid of the producing process), plus `process_name` metadata
/// naming it. Callers pick their own tid numbering within their pid.
///
/// [BuildTracer] buffers events and writes them as Chrome Trace Event
/// Format JSON. Events mix complete spans (`ph: "X"`), metadata
/// (`ph: "M"`), and flow events (`ph: "s"` / `"f"`) in a single list so
/// Perfetto sees a coherent trace when the file is merged by a parent
/// process.
///
/// Lookalike helpers on this class match the shape of
/// `dart:developer`'s `Timeline`:
/// * [trace] / [traceAsync] → `Timeline.timeSync` / `timeSync` async.
/// * [timeSubprocess] / [timeSubprocessAsync] → scoped wrappers around
/// `Process.runSync` / `Process.start` that emit a subprocess span.
/// * [recordNetworkSpan] → HTTP-request span with the standard args
/// shape (method/host/status/error).
///
/// See also [PhaseTracker] for the `transitionTo(nextPhase)` pattern
/// used when parsing a subprocess's verbose output.
class BuildTracer {
/// Private backing field for [current]. Producers never touch this
/// directly — [start] / [stop] manage it, and [runAsync] wraps the
/// two in a try/finally for callers that can afford a closure.
static BuildTracer? _current;
/// The tracer for the in-progress build, if any. Set by [start] (or
/// by the [runAsync] wrapper around it) so deep layers (network,
/// subprocess wrappers) can record spans without plumbing a
/// parameter through every signature. Null when no producer has a
/// build in progress.
static BuildTracer? get current => _current;
/// Installs [tracer] as [current]. Producers that can wrap a body
/// in a closure should prefer [runAsync] — it pairs [start] with
/// [stop] in a try/finally. Call [stop] when the build finishes.
///
/// Throws [StateError] if a tracer is already installed: there's
/// only one [current] at a time and overlapping producers would
/// overwrite each other's spans. Use [runAsync] if you need nested
/// installs (it saves/restores the prior value).
static void start(BuildTracer tracer) {
if (_current != null) {
throw StateError(
'BuildTracer already installed; call stop() before starting a new one '
'or use runAsync() for nested installs.',
);
}
_current = tracer;
}
/// Clears [current]. Idempotent — safe to call when nothing is
/// installed, so error paths can invoke it unconditionally. Pair
/// with [start].
static void stop() {
_current = null;
}
/// Runs [body] with [tracer] installed as [current] for its duration
/// (including any async work it awaits). Unwinds on return or throw
/// so [current] is guaranteed cleared — producers don't have to pair
/// [start] / [stop] calls themselves.
///
/// Saves and restores the prior [current] so nested calls compose.
static Future<T> runAsync<T>(
BuildTracer tracer,
Future<T> Function() body,
) async {
final prev = _current;
_current = tracer;
try {
return await body();
} finally {
_current = prev;
}
}
/// Raw JSON maps: complete spans (ph:"X"), metadata (ph:"M"), and flow
/// events (ph:"s"/"f") share the buffer so each consumer can emit any
/// of them without a stricter typed API.
final List<Map<String, Object?>> _events = <Map<String, Object?>>[];
/// Number of events recorded so far. Used mainly by tests.
int get eventCount => _events.length;
/// Unmodifiable view of the raw event maps, for tests that need to
/// inspect individual spans without round-tripping through a file.
List<Map<String, Object?>> get events => List.unmodifiable(_events);
/// Adds a completed span (`ph: "X"`).
void addCompleteEvent({
required String name,
required String cat,
required int pid,
required int tid,
required DateTime start,
required DateTime end,
Map<String, Object?>? args,
}) {
_events.add(
BuildTraceEvent(
name: name,
cat: cat,
pid: pid,
tid: tid,
start: start,
duration: end.difference(start),
args: args,
).toJson(),
);
}
/// Emits a `process_name` metadata event so Perfetto shows [name] in
/// place of the bare pid number.
void addProcessNameMetadata({required int pid, required String name}) {
_events.add(<String, Object?>{
'name': 'process_name',
'ph': 'M',
'pid': pid,
'args': <String, Object?>{'name': name},
});
}
/// Emits a `thread_name` metadata event so Perfetto shows [name] on the
/// row for ([pid], [tid]).
void addThreadNameMetadata({
required int pid,
required int tid,
required String name,
}) {
_events.add(<String, Object?>{
'name': 'thread_name',
'ph': 'M',
'pid': pid,
'tid': tid,
'args': <String, Object?>{'name': name},
});
}
/// Emits a flow-start event (`ph: "s"`) tying the enclosing span at
/// ([pid], [tid], [at]) to a flow-end event a spawned child will
/// emit with the same [id]. Shorebird convention uses the child's pid
/// as the flow id so spawner and spawnee agree on the id without
/// passing it through env vars.
void addFlowStart({
required int id,
required int pid,
required int tid,
required DateTime at,
}) {
_events.add(<String, Object?>{
'ph': 's',
'name': 'spawn',
'cat': 'flow',
'id': id,
'ts': at.microsecondsSinceEpoch,
'pid': pid,
'tid': tid,
'bp': 'e',
});
}
/// Emits a flow-end event (`ph: "f"`) tying this producer's span to a
/// flow the parent process started with `ph: "s"` under the same [id].
void addFlowEnd({
required int id,
required int pid,
required int tid,
required DateTime at,
}) {
_events.add(<String, Object?>{
'ph': 'f',
'name': 'spawn',
'cat': 'flow',
'id': id,
'ts': at.microsecondsSinceEpoch,
'pid': pid,
'tid': tid,
'bp': 'e',
});
}
/// Runs [body], times it, and emits a complete span describing it.
/// Matches `dart:developer`'s `Timeline.timeSync<T>()`. Exceptions
/// propagate; the span is still recorded via a try/finally.
T trace<T>({
required String name,
required String cat,
required int pid,
required int tid,
required T Function() body,
Map<String, Object?>? args,
}) {
final start = DateTime.now();
try {
return body();
} finally {
addCompleteEvent(
name: name,
cat: cat,
pid: pid,
tid: tid,
start: start,
end: DateTime.now(),
args: args,
);
}
}
/// Async variant of [trace]. Span is recorded once [body] completes
/// (or throws).
Future<T> traceAsync<T>({
required String name,
required String cat,
required int pid,
required int tid,
required Future<T> Function() body,
Map<String, Object?>? args,
}) async {
final start = DateTime.now();
try {
return await body();
} finally {
addCompleteEvent(
name: name,
cat: cat,
pid: pid,
tid: tid,
start: start,
end: DateTime.now(),
args: args,
);
}
}
/// Adds a span describing a subprocess invocation whose timing the
/// caller already measured. Span name is the [executable] basename;
/// the full argv lands in `args.argv`. Prefer [timeSubprocess] /
/// [timeSubprocessAsync] when you're *about* to run the process;
/// this helper is for call sites that already have start/end
/// timestamps (e.g. an existing stopwatch-around-run pattern).
void addSubprocessEvent({
required String executable,
required List<String> arguments,
required int pid,
required int tid,
required DateTime start,
required DateTime end,
}) {
addCompleteEvent(
name: _basename(executable),
cat: 'subprocess',
pid: pid,
tid: tid,
start: start,
end: end,
args: <String, Object?>{'argv': arguments},
);
}
/// Emits a span that covers a subprocess invocation. [runner] should
/// invoke [Process.runSync] (or equivalent) with [executable] and
/// [arguments]; the span wraps it with start/end timestamps, and the
/// executable basename + full argv end up in the Perfetto span pane.
ProcessResult timeSubprocess({
required String executable,
required List<String> arguments,
required int pid,
required int tid,
required ProcessResult Function() runner,
}) {
final start = DateTime.now();
final result = runner();
addCompleteEvent(
name: _basename(executable),
cat: 'subprocess',
pid: pid,
tid: tid,
start: start,
end: DateTime.now(),
args: <String, Object?>{'argv': arguments},
);
return result;
}
/// Async variant of [timeSubprocess] for callers that use
/// [Process.run] or `processManager.run`.
Future<ProcessResult> timeSubprocessAsync({
required String executable,
required List<String> arguments,
required int pid,
required int tid,
required Future<ProcessResult> Function() runner,
}) async {
final start = DateTime.now();
try {
return await runner();
} finally {
addCompleteEvent(
name: _basename(executable),
cat: 'subprocess',
pid: pid,
tid: tid,
start: start,
end: DateTime.now(),
args: <String, Object?>{'argv': arguments},
);
}
}
/// Spawns [executable] via [Process.start], waits for it, and emits
/// metadata + subprocess span on the child's real OS pid — each
/// subprocess shows up as its own process in Perfetto, not a row
/// inside the parent.
///
/// Returns a [ProcessResult] with the same shape [Process.run] would
/// have produced (stdout and stderr decoded via [systemEncoding]) so
/// callers can swap `Process.run` → this helper without changing the
/// surrounding code.
///
/// [workingDirectory] and [environment] are forwarded to
/// [Process.start].
Future<ProcessResult> startAndTraceSubprocess({
required String executable,
required List<String> arguments,
String? workingDirectory,
Map<String, String>? environment,
}) async {
final start = DateTime.now();
final process = await Process.start(
executable,
arguments,
workingDirectory: workingDirectory,
environment: environment,
);
final childPid = process.pid;
final stdoutF = process.stdout.transform(systemEncoding.decoder).join();
final stderrF = process.stderr.transform(systemEncoding.decoder).join();
final exitCode = await process.exitCode;
final streams = await Future.wait([stdoutF, stderrF]);
final end = DateTime.now();
final name = _basename(executable);
addProcessNameMetadata(pid: childPid, name: name);
addThreadNameMetadata(pid: childPid, tid: 1, name: name);
addSubprocessEvent(
executable: executable,
arguments: arguments,
pid: childPid,
tid: 1,
start: start,
end: end,
);
return ProcessResult(childPid, exitCode, streams[0], streams[1]);
}
/// Records an HTTP request span. Name is "METHOD host" so requests to
/// the same host collapse visually in Perfetto. [args] augments the
/// standard `{method, host}` with optional `status`, `contentLength`,
/// `error`.
void recordNetworkSpan({
required String method,
required String host,
required int pid,
required int tid,
required DateTime start,
required DateTime end,
int? status,
int? contentLength,
String? error,
}) {
addCompleteEvent(
name: '$method $host',
cat: 'network',
pid: pid,
tid: tid,
start: start,
end: end,
args: <String, Object?>{
'method': method,
'host': host,
if (status != null) 'status': status,
if (contentLength != null) 'contentLength': contentLength,
if (error != null) 'error': error,
},
);
}
/// Reads a trace JSON file written by a subprocess and appends its
/// events (complete spans, metadata, and flow events) as-is.
void mergeEventsFromFile(File file) {
if (!file.existsSync()) {
return;
}
try {
final decoded = json.decode(file.readAsStringSync());
if (decoded is! List) return;
for (final item in decoded) {
if (item is Map<String, Object?>) {
_events.add(item);
}
}
} on FormatException {
// Corrupt trace — skip it rather than abort the outer build.
}
}
/// Writes events to [file] as a JSON array, merging with any events
/// already there. Existing non-list / unreadable content is
/// overwritten; missing parent directories are created.
///
/// If [existingEvents] is provided, it is used in place of re-reading
/// [file]. Callers that have already parsed [file] (e.g. to decide
/// whether to merge at all) can pass the parsed events here to avoid
/// a redundant read-and-parse.
void writeToFile(
File file, {
List<Map<String, Object?>>? existingEvents,
}) {
final merged = <Map<String, Object?>>[];
if (existingEvents != null) {
merged.addAll(existingEvents);
} else if (file.existsSync()) {
try {
final decoded = json.decode(file.readAsStringSync());
if (decoded is List) {
for (final item in decoded) {
if (item is Map<String, Object?>) {
merged.add(item);
}
}
}
} on FormatException {
// Ignore corrupt existing trace — overwrite with our events.
}
}
merged.addAll(_events);
if (!file.parent.existsSync()) {
file.parent.createSync(recursive: true);
}
file.writeAsStringSync(json.encode(merged));
}
}
String _basename(String path) {
final sep = path.lastIndexOf(Platform.pathSeparator);
return sep < 0 ? path : path.substring(sep + 1);
}
@@ -0,0 +1,68 @@
import 'package:shorebird_build_trace/src/build_tracer.dart';
/// Records a span each time a new named phase begins. Used when parsing
/// a subprocess's verbose output to attribute time to sub-phases of a
/// larger operation (e.g. `pod install: analyzing`, `pod install:
/// downloading`, ...).
///
/// Call [transitionTo] with each new phase name as you detect it; the
/// previous phase's span is emitted at that point. Call
/// `transitionTo(null)` (or just [end]) when the enclosing subprocess
/// exits so the last phase's span is flushed.
class PhaseTracker {
/// Creates a [PhaseTracker] that will record spans on [tracer]
/// for each phase transition, using ([pid], [tid]) for layout and
/// prefixing each span name with "[namePrefix]: ".
PhaseTracker({
required this.tracer,
required this.pid,
required this.tid,
required this.namePrefix,
this.cat = 'subprocess',
});
/// The tracer to record spans on.
final BuildTracer tracer;
/// Process id for emitted spans.
final int pid;
/// Thread id for emitted spans.
final int tid;
/// Span name is `"$namePrefix: $phase"`.
final String namePrefix;
/// Span category.
final String cat;
String? _currentPhase;
DateTime? _currentStart;
/// Moves to [nextPhase]. If a previous phase was in progress, its span
/// is recorded first. Pass null to close the current phase without
/// starting a new one.
void transitionTo(String? nextPhase) {
final now = DateTime.now();
// Pull into locals so flow analysis promotes them to non-null; the
// outer variables don't promote inside a closure context.
final previousPhase = _currentPhase;
final previousStart = _currentStart;
if (previousPhase != null && previousStart != null) {
tracer.addCompleteEvent(
name: '$namePrefix: $previousPhase',
cat: cat,
pid: pid,
tid: tid,
start: previousStart,
end: now,
);
}
_currentPhase = nextPhase;
_currentStart = nextPhase == null ? null : now;
}
/// Closes the current phase (if any). Shorthand for
/// `transitionTo(null)`.
void end() => transitionTo(null);
}
@@ -0,0 +1,8 @@
import 'dart:io' show pid;
/// The OS process id of the current Dart process.
///
/// Trivial re-export of `dart:io`'s top-level [pid] getter so call sites
/// read as "the thing that tagged this span" rather than reaching into
/// `dart:io` for one name.
int currentProcessId() => pid;
@@ -0,0 +1,34 @@
import 'dart:io';
import 'package:shorebird_build_trace/src/build_tracer.dart';
/// Runs [executable] with [arguments] via [Process.start], tracing the
/// subprocess on its own OS pid when a [BuildTracer] is installed via
/// [BuildTracer.runAsync]. Returns a [ProcessResult] with the same
/// shape as [Process.run] would, so callers can swap `Process.run` for
/// this helper without changing surrounding code.
///
/// Callers never reference [BuildTracer.current] directly; this helper
/// is the single place that branches on "is tracing on?".
Future<ProcessResult> runSubprocess(
String executable,
List<String> arguments, {
String? workingDirectory,
Map<String, String>? environment,
}) {
final tracer = BuildTracer.current;
if (tracer == null) {
return Process.run(
executable,
arguments,
workingDirectory: workingDirectory,
environment: environment,
);
}
return tracer.startAndTraceSubprocess(
executable: executable,
arguments: arguments,
workingDirectory: workingDirectory,
environment: environment,
);
}
@@ -0,0 +1,211 @@
/// String-level API contract between the trace producers (flutter_tools,
/// aot_tools, CocoaPods wrapper, the Gradle init script) and the trace
/// consumer (shorebird_cli's `build_trace_summary.dart`).
///
/// Once a name lands in a shipped flutter or aot_tools, shorebird_cli
/// has to understand it across every version pin that ships with a
/// Shorebird release. So:
///
/// * **Never rename** a constant here. Add a new one and have shorebird
/// recognize both.
/// * **Don't reuse** a removed constant's value for a different meaning
/// for the same reason.
/// * The Groovy init script (`shorebird_trace_init.gradle` in the
/// flutter fork) can't import Dart — its literals are hand-kept in
/// sync with this file. The init script carries a comment pointing
/// here.
library;
/// Chrome Trace Event `cat` values emitted by the producers.
///
/// Producers emit via [wireName]. Consumers (shorebird_cli) parse via
/// [tryParse], which returns null for unknown values so callers can
/// map them to [TraceCategory.unknown] and the switch stays
/// exhaustive. Adding a new category here is safe: older consumers
/// see it as `unknown` (dropped), newer consumers bucket it.
enum TraceCategory {
/// Flutter-tool setup / teardown and outer `flutter build <target>`
/// spans.
flutter('flutter'),
/// Native build system (Gradle, Xcode) outer spans.
gradle('gradle'),
/// See [gradle].
xcode('xcode'),
/// Per-task events emitted by `shorebird_trace_init.gradle`.
gradleTask('gradle_task'),
/// Per-subsection events parsed from xcresulttool's structured log.
xcodeSubsection('xcode_subsection'),
/// `flutter assemble` target spans.
assemble('assemble'),
/// Child processes traced via `BuildTracer.startAndTraceSubprocess`
/// or `BuildTracer.timeSubprocess` — also the category the
/// CocoaPods wrapper emits phase spans under.
subprocess('subprocess'),
/// HTTP request spans (artifact fetches + auth/upload).
network('network'),
/// Consumer-side fallback for a category emitted by a future
/// producer version that this consumer doesn't yet recognize. Never
/// emitted on the wire.
unknown('');
const TraceCategory(this.wireName);
/// The exact string a producer emits on the `cat` field. Read by
/// consumers via [tryParse].
final String wireName;
/// Total parse: returns [unknown] for a null or unrecognized wire
/// value so consumers can switch exhaustively without coercing.
static TraceCategory parse(String? wire) {
if (wire == null) return unknown;
for (final c in values) {
if (c != unknown && c.wireName == wire) return c;
}
return unknown;
}
}
/// Classification of Gradle task names performed by
/// `shorebird_trace_init.gradle`, emitted in each gradle_task event's
/// `args["kind"]`. Consumer-side enum with the same forward-compat
/// rules as [TraceCategory].
enum GradleTaskKind {
/// Kotlin compilation tasks.
kotlinCompile('kotlin_compile'),
/// Java compilation tasks (including AGP's precompile scaffolding).
javaCompile('java_compile'),
/// DEX conversion.
dex('dex'),
/// Resource processing / manifest merging / R-file generation.
resources('resources'),
/// AGP artifact transforms.
transform('transform'),
/// R8 / minification.
r8Minify('r8_minify'),
/// Android lint.
lint('lint'),
/// Native library linking.
nativeLink('native_link'),
/// Flutter gradle plugin's own tasks.
flutterGradlePlugin('flutter_gradle_plugin'),
/// `bundle*` tasks.
bundle('bundle'),
/// `package*` tasks (non-plugin).
packaging('packaging'),
/// AIDL.
aidl('aidl'),
/// Gradle's per-plugin / per-variant scaffolding (metadata, proguard
/// rule export, pre-/post-compile bookkeeping).
gradleScaffold('gradle_scaffold'),
/// Catch-all for tasks the init script's classifier didn't match.
/// Also the consumer-side fallback for unrecognized wire values.
other('other');
const GradleTaskKind(this.wireName);
/// The exact string the init script emits on `args["kind"]`.
final String wireName;
/// Total parse: returns [other] for a null or unrecognized wire
/// value so consumers can switch exhaustively without coercing.
static GradleTaskKind parse(String? wire) {
if (wire == null) return other;
for (final k in values) {
if (k.wireName == wire) return k;
}
return other;
}
}
/// Span name prefixes emitted by the producers. These are format
/// strings ("gradle " then the task name) rather than enumerated
/// values, so they stay as constants; see [TraceCategory] /
/// [GradleTaskKind] for the enumerated vocabularies.
class TraceNames {
// coverage:ignore-start
/// Private constructor — [TraceNames] only holds static members, so an
/// instance is never created (and this line is never run).
TraceNames._();
// coverage:ignore-end
/// Prefix for the outer flutter build span. Span name is
/// `"flutter build <target>"` (target = apk / appbundle / ios / ipa).
/// shorebird matches with `startsWith`.
static const String flutterBuildSpanPrefix = 'flutter build ';
/// Prefix for the outer gradle span. Span name is
/// `"gradle <assembleTask>"`.
static const String gradleSpanPrefix = 'gradle ';
/// Prefix for the outer xcode span. Span name is
/// `"xcode <action>"` (build / archive / install).
static const String xcodeSpanPrefix = 'xcode ';
/// Name prefix the CocoaPods phase tracker emits phase spans under.
/// Full span name shorebird matches is `"pod install: <phase>"`.
static const String podInstallNamePrefix = 'pod install';
/// Name of the outer `pod install` span (emitted separately from
/// phase sub-spans).
static const String podInstallSpanName = 'pod install';
}
/// Phases identified by the CocoaPods verbose-output parser. Producer
/// side (flutter_tools) picks the value, [PhaseTracker] stringifies it
/// with [TraceNames.podInstallNamePrefix] as prefix. Consumer side
/// (shorebird_cli) matches the assembled span name against this enum's
/// [wireName]s.
enum PodInstallPhase {
/// Seen when `pod install` logs `Analyzing dependencies`.
analyzing('analyzing'),
/// Seen when `pod install` logs `Downloading dependencies`.
downloading('downloading'),
/// Seen when `pod install` logs `Generating Pods project`.
generating('generating'),
/// Seen when `pod install` logs `Integrating client project`.
integrating('integrating'),
/// Consumer-side fallback for a phase name a future producer
/// version might emit but this consumer doesn't recognize. Never
/// emitted on the wire.
other('');
const PodInstallPhase(this.wireName);
/// The phase name used in the emitted span (`"pod install: <wireName>"`).
final String wireName;
/// Total parse: returns [other] for a null or unrecognized wire
/// value so consumers can bucket uniformly without coercing.
static PodInstallPhase parse(String? wire) {
if (wire == null) return other;
for (final p in values) {
if (p != other && p.wireName == wire) return p;
}
return other;
}
}
@@ -0,0 +1,17 @@
name: shorebird_build_trace
description: >
Chrome Trace Event Format producer used by Shorebird's build-trace
plumbing across flutter_tools, dart-sdk's aot_tools, and shorebird_cli.
Not intended for consumption outside of those projects.
version: 0.1.0
publish_to: none
homepage: https://shorebird.dev
repository: https://github.com/shorebirdtech/shorebird/tree/main/packages/shorebird_build_trace
resolution: workspace
environment:
sdk: ^3.9.0
dev_dependencies:
test: ^1.31.0
very_good_analysis: ^10.0.0
@@ -0,0 +1,575 @@
import 'dart:convert';
import 'dart:io';
import 'package:shorebird_build_trace/shorebird_build_trace.dart';
import 'package:test/test.dart';
void main() {
group(BuildTraceEvent, () {
test('toJson produces a Chrome Trace Event Format complete event', () {
final event = BuildTraceEvent(
name: 'gen_snapshot',
cat: 'subprocess',
start: DateTime.fromMicrosecondsSinceEpoch(100),
duration: const Duration(microseconds: 200),
pid: 42,
tid: 1,
args: {
'argv': ['--foo'],
},
);
expect(event.toJson(), {
'ph': 'X',
'name': 'gen_snapshot',
'cat': 'subprocess',
'ts': 100,
'dur': 200,
'pid': 42,
'tid': 1,
'args': {
'argv': ['--foo'],
},
});
});
test('toJson omits args when null', () {
final event = BuildTraceEvent(
name: 'x',
cat: 'c',
start: DateTime.fromMicrosecondsSinceEpoch(0),
duration: const Duration(microseconds: 1),
pid: 1,
tid: 1,
);
expect(event.toJson().containsKey('args'), isFalse);
});
test('fromJson round-trips', () {
final event = BuildTraceEvent(
name: 'n',
cat: 'c',
start: DateTime.fromMicrosecondsSinceEpoch(10),
duration: const Duration(microseconds: 20),
pid: 7,
tid: 1,
args: {'a': 1},
);
final parsed = BuildTraceEvent.fromJson(event.toJson());
expect(parsed.name, 'n');
expect(parsed.cat, 'c');
expect(parsed.start, DateTime.fromMicrosecondsSinceEpoch(10));
expect(parsed.duration, const Duration(microseconds: 20));
expect(parsed.pid, 7);
expect(parsed.tid, 1);
expect(parsed.args, {'a': 1});
});
});
group(BuildTracer, () {
late Directory tempDir;
late File traceFile;
setUp(() {
tempDir = Directory.systemTemp.createTempSync('build_trace_test_');
traceFile = File('${tempDir.path}/trace.json');
});
tearDown(() {
if (tempDir.existsSync()) tempDir.deleteSync(recursive: true);
});
test('addCompleteEvent records one ph:X event', () {
BuildTracer()
..addCompleteEvent(
name: 'x',
cat: 'c',
pid: 1,
tid: 1,
start: DateTime.fromMicrosecondsSinceEpoch(100),
end: DateTime.fromMicrosecondsSinceEpoch(500),
)
..writeToFile(traceFile);
final decoded = jsonDecode(traceFile.readAsStringSync()) as List;
expect(decoded, hasLength(1));
final e = decoded.single as Map<String, Object?>;
expect(e['ph'], 'X');
expect(e['dur'], 400);
});
test('addProcessNameMetadata emits ph:M process_name', () {
BuildTracer()
..addProcessNameMetadata(pid: 1, name: 'foo')
..writeToFile(traceFile);
final m =
(jsonDecode(traceFile.readAsStringSync()) as List).single
as Map<String, Object?>;
expect(m['ph'], 'M');
expect(m['name'], 'process_name');
expect((m['args']! as Map)['name'], 'foo');
});
test('addThreadNameMetadata emits ph:M thread_name', () {
BuildTracer()
..addThreadNameMetadata(pid: 1, tid: 5, name: 'network')
..writeToFile(traceFile);
final m =
(jsonDecode(traceFile.readAsStringSync()) as List).single
as Map<String, Object?>;
expect(m['ph'], 'M');
expect(m['name'], 'thread_name');
expect(m['tid'], 5);
expect((m['args']! as Map)['name'], 'network');
});
test('addFlowStart / addFlowEnd emit ph:s / ph:f with bp=e', () {
final t = BuildTracer()
..addFlowStart(
id: 99,
pid: 1,
tid: 1,
at: DateTime.fromMicrosecondsSinceEpoch(10),
)
..addFlowEnd(
id: 99,
pid: 2,
tid: 1,
at: DateTime.fromMicrosecondsSinceEpoch(50),
);
t.writeToFile(traceFile);
final events = jsonDecode(traceFile.readAsStringSync()) as List;
expect((events[0] as Map)['ph'], 's');
expect((events[0] as Map)['id'], 99);
expect((events[0] as Map)['bp'], 'e');
expect((events[1] as Map)['ph'], 'f');
expect((events[1] as Map)['id'], 99);
});
test('trace<T> records a span around a sync body', () {
final t = BuildTracer();
final result = t.trace<int>(
name: 'work',
cat: 'c',
pid: 1,
tid: 1,
body: () => 42,
);
expect(result, 42);
expect(t.eventCount, 1);
});
test('trace<T> records a span even when body throws', () {
final t = BuildTracer();
expect(
() => t.trace<int>(
name: 'work',
cat: 'c',
pid: 1,
tid: 1,
body: () => throw StateError('boom'),
),
throwsA(isA<StateError>()),
);
expect(t.eventCount, 1);
});
test('traceAsync records a span around an async body', () async {
final t = BuildTracer();
final result = await t.traceAsync<int>(
name: 'work',
cat: 'c',
pid: 1,
tid: 1,
body: () async => 7,
);
expect(result, 7);
expect(t.eventCount, 1);
});
test('timeSubprocess emits a subprocess span and returns the result', () {
final t = BuildTracer();
final result = t.timeSubprocess(
executable: '/usr/bin/true',
arguments: const [],
pid: 1,
tid: 2,
runner: () => ProcessResult(100, 0, '', ''),
);
expect(result.exitCode, 0);
t.writeToFile(traceFile);
final e =
(jsonDecode(traceFile.readAsStringSync()) as List).single
as Map<String, Object?>;
expect(e['name'], 'true');
expect(e['cat'], 'subprocess');
});
test(
'timeSubprocessAsync records a span even when runner throws',
() async {
final t = BuildTracer();
await expectLater(
t.timeSubprocessAsync(
executable: 'diff',
arguments: const ['-u'],
pid: 1,
tid: 2,
runner: () async => throw StateError('boom'),
),
throwsA(isA<StateError>()),
);
expect(t.eventCount, 1);
},
);
test('recordNetworkSpan formats name + args', () {
BuildTracer()
..recordNetworkSpan(
method: 'GET',
host: 'api.example.com',
pid: 1,
tid: 3,
start: DateTime.fromMicrosecondsSinceEpoch(0),
end: DateTime.fromMicrosecondsSinceEpoch(1000),
status: 200,
contentLength: 42,
)
..writeToFile(traceFile);
final e =
(jsonDecode(traceFile.readAsStringSync()) as List).single
as Map<String, Object?>;
expect(e['name'], 'GET api.example.com');
expect(e['cat'], 'network');
final args = e['args']! as Map;
expect(args['method'], 'GET');
expect(args['host'], 'api.example.com');
expect(args['status'], 200);
expect(args['contentLength'], 42);
});
test('writeToFile merges with existing events', () {
traceFile.writeAsStringSync(
jsonEncode([
{
'ph': 'X',
'name': 'old',
'cat': 'c',
'ts': 0,
'dur': 1,
'pid': 1,
'tid': 1,
},
]),
);
BuildTracer()
..addCompleteEvent(
name: 'new',
cat: 'c',
pid: 1,
tid: 1,
start: DateTime.fromMicrosecondsSinceEpoch(0),
end: DateTime.fromMicrosecondsSinceEpoch(1),
)
..writeToFile(traceFile);
final decoded = jsonDecode(traceFile.readAsStringSync()) as List;
expect(decoded, hasLength(2));
expect((decoded[0] as Map)['name'], 'old');
expect((decoded[1] as Map)['name'], 'new');
});
test('writeToFile uses existingEvents instead of re-reading file', () {
// Write something to the file that, if read, would corrupt the
// merge output. Passing existingEvents should make writeToFile
// ignore the file contents entirely.
traceFile.writeAsStringSync('garbage that would fail to parse');
BuildTracer()
..addCompleteEvent(
name: 'new',
cat: 'c',
pid: 1,
tid: 1,
start: DateTime.fromMicrosecondsSinceEpoch(0),
end: DateTime.fromMicrosecondsSinceEpoch(1),
)
..writeToFile(
traceFile,
existingEvents: [
{
'ph': 'X',
'name': 'provided',
'cat': 'c',
'ts': 0,
'dur': 1,
'pid': 1,
'tid': 1,
},
],
);
final decoded = jsonDecode(traceFile.readAsStringSync()) as List;
expect(decoded, hasLength(2));
expect((decoded[0] as Map)['name'], 'provided');
expect((decoded[1] as Map)['name'], 'new');
});
test('writeToFile overwrites corrupt existing file', () {
traceFile.writeAsStringSync('not json');
BuildTracer()
..addCompleteEvent(
name: 'x',
cat: 'c',
pid: 1,
tid: 1,
start: DateTime.fromMicrosecondsSinceEpoch(0),
end: DateTime.fromMicrosecondsSinceEpoch(1),
)
..writeToFile(traceFile);
final decoded = jsonDecode(traceFile.readAsStringSync()) as List;
expect(decoded, hasLength(1));
});
test('writeToFile creates missing parent directories', () {
final nested = File('${tempDir.path}/a/b/c/trace.json');
BuildTracer()
..addCompleteEvent(
name: 'x',
cat: 'c',
pid: 1,
tid: 1,
start: DateTime.fromMicrosecondsSinceEpoch(0),
end: DateTime.fromMicrosecondsSinceEpoch(1),
)
..writeToFile(nested);
expect(nested.existsSync(), isTrue);
});
test('mergeEventsFromFile appends events as-is', () {
final src = File('${tempDir.path}/src.json')
..writeAsStringSync(
jsonEncode([
{
'ph': 'X',
'name': 'from-file',
'cat': 'c',
'ts': 0,
'dur': 1,
'pid': 1,
'tid': 1,
},
]),
);
final t = BuildTracer()..mergeEventsFromFile(src);
expect(t.eventCount, 1);
});
test('mergeEventsFromFile is a no-op on missing file', () {
BuildTracer()
..mergeEventsFromFile(File('${tempDir.path}/missing.json'))
..writeToFile(traceFile);
final decoded = jsonDecode(traceFile.readAsStringSync()) as List;
expect(decoded, isEmpty);
});
test('mergeEventsFromFile swallows FormatException on corrupt JSON', () {
final src = File('${tempDir.path}/src.json')
..writeAsStringSync('{not valid json[');
final t = BuildTracer()..mergeEventsFromFile(src);
expect(t.eventCount, 0);
});
test('mergeEventsFromFile skips when root is not a list', () {
final src = File('${tempDir.path}/src.json')
..writeAsStringSync('{"not": "an array"}');
final t = BuildTracer()..mergeEventsFromFile(src);
expect(t.eventCount, 0);
});
group('start / stop / current', () {
// `current` is process-global; make sure tests don't leak into each
// other if one throws mid-way.
tearDown(BuildTracer.stop);
test('current is null before any start', () {
expect(BuildTracer.current, isNull);
});
test('start installs, stop clears', () {
final t = BuildTracer();
BuildTracer.start(t);
expect(identical(BuildTracer.current, t), isTrue);
BuildTracer.stop();
expect(BuildTracer.current, isNull);
});
test('start throws StateError when a tracer is already installed', () {
BuildTracer.start(BuildTracer());
expect(
() => BuildTracer.start(BuildTracer()),
throwsA(isA<StateError>()),
);
});
test('stop is idempotent', () {
BuildTracer.stop();
BuildTracer.stop();
expect(BuildTracer.current, isNull);
});
});
group('runAsync', () {
tearDown(BuildTracer.stop);
test('installs tracer for duration of body, clears after', () async {
final t = BuildTracer();
expect(BuildTracer.current, isNull);
await BuildTracer.runAsync(t, () async {
expect(identical(BuildTracer.current, t), isTrue);
});
expect(BuildTracer.current, isNull);
});
test('clears tracer even when body throws', () async {
final t = BuildTracer();
await expectLater(
BuildTracer.runAsync<void>(t, () async {
throw StateError('boom');
}),
throwsA(isA<StateError>()),
);
expect(BuildTracer.current, isNull);
});
test('nested calls save and restore the prior current', () async {
final outer = BuildTracer();
final inner = BuildTracer();
await BuildTracer.runAsync(outer, () async {
expect(identical(BuildTracer.current, outer), isTrue);
await BuildTracer.runAsync(inner, () async {
expect(identical(BuildTracer.current, inner), isTrue);
});
expect(identical(BuildTracer.current, outer), isTrue);
});
expect(BuildTracer.current, isNull);
});
});
test(
'addSubprocessEvent emits subprocess span with executable basename',
() {
BuildTracer()
..addSubprocessEvent(
executable:
'${Platform.pathSeparator}usr'
'${Platform.pathSeparator}bin'
'${Platform.pathSeparator}diff',
arguments: const ['-u', 'a', 'b'],
pid: 1,
tid: 1,
start: DateTime.fromMicrosecondsSinceEpoch(0),
end: DateTime.fromMicrosecondsSinceEpoch(500),
)
..writeToFile(traceFile);
final e =
(jsonDecode(traceFile.readAsStringSync()) as List).single
as Map<String, Object?>;
expect(e['name'], 'diff');
expect(e['cat'], 'subprocess');
expect(e['dur'], 500);
expect((e['args']! as Map)['argv'], ['-u', 'a', 'b']);
},
);
test('recordNetworkSpan includes error when provided', () {
BuildTracer()
..recordNetworkSpan(
method: 'POST',
host: 'api.example.com',
pid: 1,
tid: 3,
start: DateTime.fromMicrosecondsSinceEpoch(0),
end: DateTime.fromMicrosecondsSinceEpoch(100),
error: 'SocketException',
)
..writeToFile(traceFile);
final e =
(jsonDecode(traceFile.readAsStringSync()) as List).single
as Map<String, Object?>;
final args = e['args']! as Map;
expect(args['error'], 'SocketException');
expect(args.containsKey('status'), isFalse);
expect(args.containsKey('contentLength'), isFalse);
});
test(
'startAndTraceSubprocess spawns a real child and records a span on '
'its OS pid',
() async {
final t = BuildTracer();
final result = await t.startAndTraceSubprocess(
executable: Platform.resolvedExecutable,
arguments: const ['--version'],
);
expect(result.exitCode, 0);
expect(result.pid, greaterThan(0));
// Expect three events for the child: process_name + thread_name
// metadata, plus the subprocess span itself. All on the child's
// real OS pid (which matches result.pid).
final byPh = <String, List<Map<String, Object?>>>{};
for (final e in t.events) {
(byPh[e['ph']! as String] ??= []).add(e);
}
expect(byPh['M'], hasLength(2));
expect(byPh['X'], hasLength(1));
for (final e in t.events) {
expect(e['pid'], result.pid);
}
expect(byPh['X']!.single['cat'], 'subprocess');
},
);
});
group(PhaseTracker, () {
test('transitionTo records span for previous phase', () {
final t = BuildTracer();
final phases =
PhaseTracker(
tracer: t,
pid: 1,
tid: 1,
namePrefix: 'pod install',
)
..transitionTo('analyzing')
..transitionTo('downloading')
..end();
expect(t.eventCount, 2);
phases.toString(); // silence unused warning if any
});
test('end closes without starting a new phase', () {
final t = BuildTracer();
PhaseTracker(
tracer: t,
pid: 1,
tid: 1,
namePrefix: 'x',
)
..transitionTo('a')
..end();
expect(t.eventCount, 1);
});
test('no events when no phase was ever started', () {
final t = BuildTracer();
PhaseTracker(tracer: t, pid: 1, tid: 1, namePrefix: 'x').end();
expect(t.eventCount, 0);
});
});
group('currentProcessId', () {
test('returns a positive integer that is stable within a process', () {
final first = currentProcessId();
expect(first, greaterThan(0));
expect(currentProcessId(), first);
});
});
}
@@ -0,0 +1,49 @@
import 'dart:io';
import 'package:shorebird_build_trace/shorebird_build_trace.dart';
import 'package:test/test.dart';
void main() {
group('runSubprocess', () {
// runSubprocess branches on BuildTracer.current, which is process-global,
// so make sure no test leaks its installation into the next.
tearDown(BuildTracer.stop);
test(
'falls through to Process.run when no tracer is installed',
() async {
expect(BuildTracer.current, isNull);
final result = await runSubprocess(
Platform.resolvedExecutable,
const ['--version'],
);
expect(result.exitCode, 0);
},
);
test(
'routes through BuildTracer.startAndTraceSubprocess when installed '
'and records a subprocess span on the child pid',
() async {
final tracer = BuildTracer();
late ProcessResult result;
await BuildTracer.runAsync(tracer, () async {
result = await runSubprocess(
Platform.resolvedExecutable,
const ['--version'],
);
});
expect(result.exitCode, 0);
expect(result.pid, greaterThan(0));
// Expect process_name + thread_name metadata + one subprocess span
// on the child's real OS pid.
final phs = tracer.events.map((e) => e['ph']).toList();
expect(phs, containsAll(<String>['M', 'M', 'X']));
for (final e in tracer.events) {
expect(e['pid'], result.pid);
}
},
);
});
}
@@ -0,0 +1,87 @@
import 'package:shorebird_build_trace/shorebird_build_trace.dart';
import 'package:test/test.dart';
void main() {
group('TraceCategory.parse', () {
test('parses each non-fallback wire value to its enum', () {
for (final c in TraceCategory.values) {
if (c == TraceCategory.unknown) continue;
expect(
TraceCategory.parse(c.wireName),
c,
reason: 'wireName "${c.wireName}" should parse to $c',
);
}
});
test('returns unknown for null', () {
expect(TraceCategory.parse(null), TraceCategory.unknown);
});
test('returns unknown for an unrecognized wire value', () {
expect(TraceCategory.parse('brand-new-category'), TraceCategory.unknown);
});
test('returns unknown for empty string (not the unknown wireName)', () {
// unknown.wireName is '' but the parse loop skips unknown, so empty
// string falls through to the fallback return.
expect(TraceCategory.parse(''), TraceCategory.unknown);
});
});
group('GradleTaskKind.parse', () {
test(
'parses each wire value to its enum (including the "other" literal)',
() {
for (final k in GradleTaskKind.values) {
expect(
GradleTaskKind.parse(k.wireName),
k,
reason: 'wireName "${k.wireName}" should parse to $k',
);
}
},
);
test('returns other for null', () {
expect(GradleTaskKind.parse(null), GradleTaskKind.other);
});
test('returns other for an unrecognized wire value', () {
expect(
GradleTaskKind.parse('brand-new-kind'),
GradleTaskKind.other,
);
});
});
group('PodInstallPhase.parse', () {
test('parses each non-fallback wire value to its enum', () {
for (final p in PodInstallPhase.values) {
if (p == PodInstallPhase.other) continue;
expect(
PodInstallPhase.parse(p.wireName),
p,
reason: 'wireName "${p.wireName}" should parse to $p',
);
}
});
test('returns other for null', () {
expect(PodInstallPhase.parse(null), PodInstallPhase.other);
});
test('returns other for an unrecognized wire value', () {
expect(
PodInstallPhase.parse('brand-new-phase'),
PodInstallPhase.other,
);
});
test('returns other for empty string (not the other wireName)', () {
// other.wireName is '' but the parse loop skips other, so empty
// string falls through to the fallback return.
expect(PodInstallPhase.parse(''), PodInstallPhase.other);
});
});
}
@@ -5,6 +5,8 @@ import 'package:shorebird_cli/src/abi.dart';
import 'package:shorebird_cli/src/android_sdk.dart'; import 'package:shorebird_cli/src/android_sdk.dart';
import 'package:shorebird_cli/src/android_studio.dart'; import 'package:shorebird_cli/src/android_studio.dart';
import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_builder/build_trace_session.dart';
import 'package:shorebird_cli/src/artifact_builder/shorebird_tracer.dart';
import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/auth/auth.dart'; import 'package:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/cache.dart'; import 'package:shorebird_cli/src/cache.dart';
@@ -32,6 +34,7 @@ import 'package:shorebird_cli/src/shorebird_validator.dart';
import 'package:shorebird_cli/src/shorebird_version.dart'; import 'package:shorebird_cli/src/shorebird_version.dart';
Future<void> main(List<String> args) async { Future<void> main(List<String> args) async {
final commandStartedAt = DateTime.now();
final loggingStdout = runScoped( final loggingStdout = runScoped(
() => LoggingStdout(baseStdOut: stdout, logFile: currentRunLogFile), () => LoggingStdout(baseStdOut: stdout, logFile: currentRunLogFile),
values: {shorebirdEnvRef}, values: {shorebirdEnvRef},
@@ -56,6 +59,9 @@ Command: shorebird ${args.join(' ')}
appleRef, appleRef,
artifactBuilderRef, artifactBuilderRef,
artifactManagerRef, artifactManagerRef,
buildTraceSessionRef.overrideWith(
() => BuildTraceSession(commandStartedAt: commandStartedAt),
),
authRef, authRef,
bundletoolRef, bundletoolRef,
cacheRef, cacheRef,
@@ -88,6 +94,7 @@ Command: shorebird ${args.join(' ')}
shorebirdArtifactsRef, shorebirdArtifactsRef,
shorebirdEnvRef, shorebirdEnvRef,
shorebirdFlutterRef, shorebirdFlutterRef,
shorebirdTracerRef,
shorebirdToolsRef, shorebirdToolsRef,
shorebirdValidatorRef, shorebirdValidatorRef,
shorebirdVersionRef, shorebirdVersionRef,
@@ -1,5 +1,6 @@
// cspell:words endtemplate aabs ipas appbundle bryanoltman codesign xcarchive // cspell:words endtemplate aabs ipas appbundle bryanoltman codesign xcarchive
// cspell:words xcframework // cspell:words xcframework
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
@@ -7,14 +8,20 @@ import 'package:collection/collection.dart';
import 'package:mason_logger/mason_logger.dart'; import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:scoped_deps/scoped_deps.dart'; import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/artifact_builder/build_environment.dart';
import 'package:shorebird_cli/src/artifact_builder/build_trace_session.dart';
import 'package:shorebird_cli/src/artifact_builder/build_trace_summary.dart';
import 'package:shorebird_cli/src/artifact_builder/shorebird_tracer.dart';
import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/os/operating_system_interface.dart'; import 'package:shorebird_cli/src/os/operating_system_interface.dart';
import 'package:shorebird_cli/src/platform.dart' as scoped_platform;
import 'package:shorebird_cli/src/platform/platform.dart'; import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/shorebird_android_artifacts.dart'; import 'package:shorebird_cli/src/shorebird_android_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_artifacts.dart'; import 'package:shorebird_cli/src/shorebird_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_documentation.dart'; import 'package:shorebird_cli/src/shorebird_documentation.dart';
import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/shorebird_process.dart'; import 'package:shorebird_cli/src/shorebird_process.dart';
/// {@template artifact_build_exception} /// {@template artifact_build_exception}
@@ -109,6 +116,7 @@ ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/new'))}
await _runShorebirdBuildCommand(() async { await _runShorebirdBuildCommand(() async {
const executable = 'flutter'; const executable = 'flutter';
final targetPlatformArgs = targetPlatforms?.targetPlatformArg; final targetPlatformArgs = targetPlatforms?.targetPlatformArg;
final traceFile = buildTraceSession.traceFile;
final arguments = [ final arguments = [
'build', 'build',
'appbundle', 'appbundle',
@@ -116,6 +124,7 @@ ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/new'))}
if (flavor != null) '--flavor=$flavor', if (flavor != null) '--flavor=$flavor',
if (target != null) '--target=$target', if (target != null) '--target=$target',
if (targetPlatformArgs != null) '--target-platform=$targetPlatformArgs', if (targetPlatformArgs != null) '--target-platform=$targetPlatformArgs',
if (traceFile != null) '--shorebird-trace=${traceFile.path}',
...args, ...args,
]; ];
@@ -126,6 +135,7 @@ ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/new'))}
// Never run in shell because we always have a fully resolved // Never run in shell because we always have a fully resolved
// executable path. // executable path.
runInShell: false, runInShell: false,
onStart: _emitFlutterSpawnFlow,
); );
if (exitCode != ExitCode.success.code) { if (exitCode != ExitCode.success.code) {
@@ -174,6 +184,7 @@ Reason: Exited with code $exitCode.''',
await _runShorebirdBuildCommand(() async { await _runShorebirdBuildCommand(() async {
const executable = 'flutter'; const executable = 'flutter';
final targetPlatformArgs = targetPlatforms?.targetPlatformArg; final targetPlatformArgs = targetPlatforms?.targetPlatformArg;
final traceFile = buildTraceSession.traceFile;
final arguments = [ final arguments = [
'build', 'build',
'apk', 'apk',
@@ -186,6 +197,7 @@ Reason: Exited with code $exitCode.''',
// coverage:ignore-start // coverage:ignore-start
if (splitPerAbi) '--split-per-abi', if (splitPerAbi) '--split-per-abi',
// coverage:ignore-end // coverage:ignore-end
if (traceFile != null) '--shorebird-trace=${traceFile.path}',
...args, ...args,
]; ];
@@ -196,6 +208,7 @@ Reason: Exited with code $exitCode.''',
// Never run in shell because we always have a fully resolved // Never run in shell because we always have a fully resolved
// executable path. // executable path.
runInShell: false, runInShell: false,
onStart: _emitFlutterSpawnFlow,
); );
if (exitCode != ExitCode.success.code) { if (exitCode != ExitCode.success.code) {
@@ -241,6 +254,7 @@ Reason: Exited with code $exitCode.''',
return _runShorebirdBuildCommand(() async { return _runShorebirdBuildCommand(() async {
const executable = 'flutter'; const executable = 'flutter';
final targetPlatformArgs = targetPlatforms?.targetPlatformArg; final targetPlatformArgs = targetPlatforms?.targetPlatformArg;
final traceFile = buildTraceSession.traceFile;
final arguments = [ final arguments = [
'build', 'build',
'aar', 'aar',
@@ -248,6 +262,7 @@ Reason: Exited with code $exitCode.''',
'--no-profile', '--no-profile',
'--build-number=$buildNumber', '--build-number=$buildNumber',
if (targetPlatformArgs != null) '--target-platform=$targetPlatformArgs', if (targetPlatformArgs != null) '--target-platform=$targetPlatformArgs',
if (traceFile != null) '--shorebird-trace=${traceFile.path}',
...args, ...args,
]; ];
@@ -258,6 +273,7 @@ Reason: Exited with code $exitCode.''',
// Never run in shell because we always have a fully resolved // Never run in shell because we always have a fully resolved
// executable path. // executable path.
runInShell: false, runInShell: false,
onStart: _emitFlutterSpawnFlow,
); );
if (exitCode != ExitCode.success.code) { if (exitCode != ExitCode.success.code) {
@@ -283,11 +299,13 @@ Reason: Exited with code $exitCode.''',
}) async { }) async {
await _runShorebirdBuildCommand(() async { await _runShorebirdBuildCommand(() async {
const executable = 'flutter'; const executable = 'flutter';
final traceFile = buildTraceSession.traceFile;
final arguments = [ final arguments = [
'build', 'build',
'linux', 'linux',
'--release', '--release',
if (target != null) '--target=$target', if (target != null) '--target=$target',
if (traceFile != null) '--shorebird-trace=${traceFile.path}',
...args, ...args,
]; ];
@@ -298,6 +316,7 @@ Reason: Exited with code $exitCode.''',
// Never run in shell because we always have a fully resolved // Never run in shell because we always have a fully resolved
// executable path. // executable path.
runInShell: false, runInShell: false,
onStart: _emitFlutterSpawnFlow,
); );
if (exitCode != ExitCode.success.code) { if (exitCode != ExitCode.success.code) {
@@ -334,6 +353,7 @@ Reason: Exited with code $exitCode.''',
String? appDillPath; String? appDillPath;
await _runShorebirdBuildCommand(() async { await _runShorebirdBuildCommand(() async {
const executable = 'flutter'; const executable = 'flutter';
final traceFile = buildTraceSession.traceFile;
final arguments = [ final arguments = [
'build', 'build',
'macos', 'macos',
@@ -341,6 +361,7 @@ Reason: Exited with code $exitCode.''',
if (flavor != null) '--flavor=$flavor', if (flavor != null) '--flavor=$flavor',
if (target != null) '--target=$target', if (target != null) '--target=$target',
if (!codesign) '--no-codesign', if (!codesign) '--no-codesign',
if (traceFile != null) '--shorebird-trace=${traceFile.path}',
...args, ...args,
]; ];
final buildStart = clock.now(); final buildStart = clock.now();
@@ -351,6 +372,7 @@ Reason: Exited with code $exitCode.''',
// Never run in shell because we always have a fully resolved // Never run in shell because we always have a fully resolved
// executable path. // executable path.
runInShell: false, runInShell: false,
onStart: _emitFlutterSpawnFlow,
); );
if (exitCode != ExitCode.success.code) { if (exitCode != ExitCode.success.code) {
@@ -399,6 +421,7 @@ Reason: Exited with code $exitCode.''',
String? appDillPath; String? appDillPath;
await _runShorebirdBuildCommand(() async { await _runShorebirdBuildCommand(() async {
const executable = 'flutter'; const executable = 'flutter';
final traceFile = buildTraceSession.traceFile;
final arguments = [ final arguments = [
'build', 'build',
'ipa', 'ipa',
@@ -406,6 +429,7 @@ Reason: Exited with code $exitCode.''',
if (flavor != null) '--flavor=$flavor', if (flavor != null) '--flavor=$flavor',
if (target != null) '--target=$target', if (target != null) '--target=$target',
if (!codesign) '--no-codesign', if (!codesign) '--no-codesign',
if (traceFile != null) '--shorebird-trace=${traceFile.path}',
...args, ...args,
]; ];
@@ -417,6 +441,7 @@ Reason: Exited with code $exitCode.''',
// Never run in shell because we always have a fully resolved // Never run in shell because we always have a fully resolved
// executable path. // executable path.
runInShell: false, runInShell: false,
onStart: _emitFlutterSpawnFlow,
); );
if (exitCode != ExitCode.success.code) { if (exitCode != ExitCode.success.code) {
@@ -460,11 +485,13 @@ Reason: Exited with code $exitCode.''',
String? appDillPath; String? appDillPath;
await _runShorebirdBuildCommand(() async { await _runShorebirdBuildCommand(() async {
const executable = 'flutter'; const executable = 'flutter';
final traceFile = buildTraceSession.traceFile;
final arguments = [ final arguments = [
'build', 'build',
'ios-framework', 'ios-framework',
'--no-debug', '--no-debug',
'--no-profile', '--no-profile',
if (traceFile != null) '--shorebird-trace=${traceFile.path}',
...args, ...args,
]; ];
@@ -476,6 +503,7 @@ Reason: Exited with code $exitCode.''',
// Never run in shell because we always have a fully resolved // Never run in shell because we always have a fully resolved
// executable path. // executable path.
runInShell: false, runInShell: false,
onStart: _emitFlutterSpawnFlow,
); );
if (exitCode != ExitCode.success.code) { if (exitCode != ExitCode.success.code) {
@@ -504,6 +532,147 @@ Reason: Exited with code $exitCode.''',
return AppleBuildResult(kernelFile: File(appDillPath!)); return AppleBuildResult(kernelFile: File(appDillPath!));
} }
/// Prepares build tracing for the current command invocation. Populates
/// [BuildTraceSession.traceFile] and [BuildTraceSession.platform] so that
/// downstream `flutter build`, `aot_tools`, and gen_snapshot calls can
/// emit into the same trace file — and so [writeBuildTraceSummary] can
/// pick up the right platform later.
///
/// Call from the outer `release_command` / `patch_command` once per
/// platform, *after* the target Flutter revision has been installed and
/// the `shorebirdEnv` override is active so the version gate checks the
/// correct revision.
///
/// No-op on older Flutter pins that don't support `--shorebird-trace`
/// (leaves session fields null → builders emit no tracing args).
Future<void> prepareBuildTrace({required String platform}) async {
buildTraceSession.platform = platform;
final revision = shorebirdEnv.flutterRevision;
final flutterVersion = await shorebirdFlutter.resolveFlutterVersion(
revision,
);
// Treat an unknown version (e.g. a pinned dev revision) as new enough,
// matching the pattern used for other version-gated features.
final supportsTrace = buildTraceSupportConstraint.isSatisfiedBy(
version: flutterVersion ?? buildTraceSupportConstraint.minVersion,
revision: revision,
);
if (!supportsTrace) {
buildTraceSession.traceFile = null;
return;
}
final traceFile = File(
p.join(
shorebirdEnv.buildDirectory.path,
'shorebird',
'debug',
'build-trace-$platform.json',
),
);
traceFile.parent.createSync(recursive: true);
buildTraceSession.traceFile = traceFile;
}
/// Emits a flow-start event (`ph: "s"`) on the shorebird_cli tracer
/// tied to the spawned flutter process's real pid. When flutter builds
/// with `--shorebird-trace`, it records a flow-end with its own pid as
/// the flow id — Perfetto draws an arrow from our spawn point into
/// flutter's first span.
void _emitFlutterSpawnFlow(Process flutter) {
shorebirdTracer.addSpawnFlowStart(id: flutter.pid, at: clock.now());
}
/// Returns the user's home directory as understood by the OS, or
/// null if neither `HOME` nor `USERPROFILE` is set. Reads from the
/// scoped [platform] (same pattern as e.g. `android_studio.dart`)
/// rather than static `Platform.environment` so tests can inject a
/// fake environment.
Directory? _homeDirectory() {
final env = scoped_platform.platform.environment;
final h = env['HOME'] ?? env['USERPROFILE'];
if (h == null || h.isEmpty) return null;
return Directory(h);
}
/// Writes a privacy-safe summary JSON (`build-trace-<platform>-summary.json`)
/// next to [BuildTraceSession.traceFile]. Best-effort: logs at detail level
/// on failure. Caches the parsed summary on [BuildTraceSession.summary] so
/// `release_command` / `patch_command` can attach it to the outgoing
/// metadata blob without re-parsing the trace file.
///
/// Uses [BuildTraceSession.commandStartedAt] to derive the wall-clock time
/// Shorebird itself spent around the Flutter build, subtracting Flutter's
/// reported total (the "flutter build X" umbrella event).
///
/// Call from the outer `release_command` / `patch_command` *after* all
/// post-flutter-build work (aot_tools, gen_snapshot, artifact uploads) has
/// completed, so their events are included in the aggregates.
void writeBuildTraceSummary() {
final traceFile = buildTraceSession.traceFile;
final buildPlatform = buildTraceSession.platform;
if (traceFile == null || buildPlatform == null) return;
// Merge Shorebird-side events (HTTP calls, subprocess spans, phase
// markers accumulated since `main()`) into Flutter's trace file so
// both local Perfetto viewing and the aggregate summary see the
// complete picture.
shorebirdTracer.mergeInto(traceFile);
final events = BuildTraceSummary.tryReadEvents(traceFile);
if (events == null) {
logger.detail(
'Skipping build trace summary: ${traceFile.path} missing or malformed.',
);
return;
}
// First pass: measure Flutter's reported build wall clock so we can
// derive Shorebird's overhead. Second pass (below) then bakes overhead
// and environment into the final summary. Parsed events are reused so
// the (often multi-megabyte) trace file is only read once.
final flutterBuild = BuildTraceSummary.fromEvents(
events,
platform: buildPlatform,
).flutterBuild;
final totalElapsed = DateTime.now().difference(
buildTraceSession.commandStartedAt,
);
final shorebirdOverhead = totalElapsed - flutterBuild;
// Snapshot the build environment (caching config, CI provider, ...).
// This is what lets us tell, in field data, whether a slow build is
// "no caching configured" vs "slow despite caching being on".
final environment = BuildEnvironment.detect(
environment: scoped_platform.platform.environment,
homeDir: _homeDirectory(),
projectRoot: shorebirdEnv.getShorebirdProjectRoot(),
);
// If the trace reports a longer build than the command has been running
// (clock skew, malformed trace), treat overhead as zero rather than
// negative.
final summary = BuildTraceSummary.fromEvents(
events,
platform: buildPlatform,
shorebirdOverhead: shorebirdOverhead.isNegative
? Duration.zero
: shorebirdOverhead,
environment: environment,
);
// Cache for the release/patch metadata uploader to read without
// re-parsing the trace file.
buildTraceSession.summary = summary;
final summaryPath = p.join(
p.dirname(traceFile.path),
'build-trace-$buildPlatform-summary.json',
);
File(summaryPath).writeAsStringSync(
const JsonEncoder.withIndent(' ').convert(summary.toJson()),
);
logger.detail('Build trace summary written to $summaryPath');
}
/// A wrapper around [command] (which runs a `flutter build` command with /// A wrapper around [command] (which runs a `flutter build` command with
/// Shorebird's fork of Flutter) with a try/finally that runs /// Shorebird's fork of Flutter) with a try/finally that runs
/// `flutter pub get` with the system installation of Flutter to reset /// `flutter pub get` with the system installation of Flutter to reset
@@ -562,12 +731,20 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod
appDillPath, appDillPath,
]; ];
final exitCode = await process.stream( // Record a span on the shorebird_cli row so gen_snapshot time shows up
shorebirdArtifacts.getArtifactPath(artifact: genSnapshotArtifact), // in Perfetto and rolls into the trace summary's subprocess bucket.
arguments, // gen_snapshot itself doesn't emit a trace; this is the best signal we
// Never run in shell because we always have a fully resolved // have for how long native codegen took during patching.
// executable path. final exitCode = await shorebirdTracer.span<int>(
runInShell: false, name: 'gen_snapshot',
category: 'subprocess',
body: () => process.stream(
shorebirdArtifacts.getArtifactPath(artifact: genSnapshotArtifact),
arguments,
// Never run in shell because we always have a fully resolved
// executable path.
runInShell: false,
),
); );
if (exitCode != ExitCode.success.code) { if (exitCode != ExitCode.success.code) {
@@ -585,11 +762,13 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod
}) async { }) async {
await _runShorebirdBuildCommand(() async { await _runShorebirdBuildCommand(() async {
const executable = 'flutter'; const executable = 'flutter';
final traceFile = buildTraceSession.traceFile;
final arguments = [ final arguments = [
'build', 'build',
'windows', 'windows',
'--release', '--release',
if (target != null) '--target=$target', if (target != null) '--target=$target',
if (traceFile != null) '--shorebird-trace=${traceFile.path}',
...args, ...args,
]; ];
@@ -600,6 +779,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod
// Never run in shell because we always have a fully resolved // Never run in shell because we always have a fully resolved
// executable path. // executable path.
runInShell: false, runInShell: false,
onStart: _emitFlutterSpawnFlow,
); );
if (exitCode != ExitCode.success.code) { if (exitCode != ExitCode.success.code) {
@@ -0,0 +1,258 @@
import 'dart:io';
import 'package:path/path.dart' as p;
/// Lightweight, privacy-safe snapshot of the build environment that's
/// emitted into the build trace summary. Only booleans, small integer
/// counts, and a categorical CI-provider enum — no paths, no project or
/// user identifiers, no URLs.
///
/// Field-data goal: with this we can tell apart "build was slow because
/// no caching is configured" from "build was slow despite caching being
/// on", which is the question that decides whether Shorebird should
/// invest in build-caching products.
class BuildEnvironment {
/// Creates a [BuildEnvironment] directly from already-detected fields.
/// Tests use this; production code uses [BuildEnvironment.detect].
BuildEnvironment({
required this.isCi,
required this.ciProvider,
required this.gradleBuildCacheEnabled,
required this.gradleConfigurationCacheEnabled,
required this.gradleParallelEnabled,
required this.gradleDaemonEnabled,
required this.gradleDevelocityDetected,
required this.gradleInitScriptCount,
required this.iosCcacheAvailable,
});
/// Detect everything about the current process's environment that's
/// relevant to build-caching analysis. [projectRoot] is the Flutter
/// project root (used for `gradle.properties` / `settings.gradle*`
/// detection); when null, only env vars and `~/`-scoped detection runs.
factory BuildEnvironment.detect({
required Map<String, String> environment,
Directory? homeDir,
Directory? projectRoot,
}) {
final ciProvider = _detectCiProvider(environment);
// `_detectCiProvider` already falls through to 'other' when CI=true
// without a more specific provider match, so provider ⇒ isCi covers it.
final isCi = ciProvider != null;
// Gradle properties: user-global first, project-local overrides last.
File? prop(String? base, List<String> rest) {
if (base == null) return null;
return File(p.joinAll([base, ...rest]));
}
final gradleProps = <String, String>{
..._readPropsFile(
prop(homeDir?.path, const ['.gradle', 'gradle.properties']),
),
..._readPropsFile(
prop(projectRoot?.path, const ['android', 'gradle.properties']),
),
..._readPropsFile(
prop(projectRoot?.path, const ['gradle.properties']),
),
};
// Returns null when the property isn't set (caller applies its own
// default via `?? <value>`); Gradle's own defaults differ across
// properties so there's no single fallback that fits all callers.
bool? propBool(String key) {
final v = gradleProps[key];
if (v == null) return null;
return v.trim().toLowerCase() == 'true';
}
return BuildEnvironment(
isCi: isCi,
ciProvider: ciProvider,
// Gradle build cache: opt-in, default off in vanilla Gradle.
gradleBuildCacheEnabled: propBool('org.gradle.caching') ?? false,
// Gradle configuration cache: opt-in.
gradleConfigurationCacheEnabled:
propBool('org.gradle.configuration-cache') ?? false,
// Parallel project execution: default off.
gradleParallelEnabled: propBool('org.gradle.parallel') ?? false,
// Daemon: default ON (skip false-positive when explicitly disabled).
gradleDaemonEnabled: propBool('org.gradle.daemon') ?? true,
gradleDevelocityDetected: _detectDevelocity(projectRoot, homeDir),
gradleInitScriptCount: _countInitScripts(homeDir),
iosCcacheAvailable: _detectCcache(environment),
);
}
/// CI environment indicator. Aggregated; we don't care which run.
final bool isCi;
/// Categorical CI provider — null when not on CI or unknown.
final String? ciProvider;
/// `org.gradle.caching=true` present in user-global or project-level
/// `gradle.properties`. The single most actionable bit: a team without
/// this on is leaving the easiest cache win on the table.
final bool gradleBuildCacheEnabled;
/// `org.gradle.configuration-cache=true` — Gradle's newer config-time
/// cache. Less impactful than build cache but adds up.
final bool gradleConfigurationCacheEnabled;
/// `org.gradle.parallel=true`.
final bool gradleParallelEnabled;
/// `org.gradle.daemon`. Default on; explicit-false would surface here.
final bool gradleDaemonEnabled;
/// Develocity (formerly Gradle Enterprise) plugin detected in
/// `settings.gradle{.kts}` or via a user-global init script.
final bool gradleDevelocityDetected;
/// Number of `*.gradle{.kts}` files in `~/.gradle/init.d/`. Init scripts
/// are how teams typically auto-apply remote-cache plugins.
final int gradleInitScriptCount;
/// `ccache` binary available on PATH — could front xcodebuild's clang.
final bool iosCcacheAvailable;
/// JSON form. All fields are upload-safe (booleans, small ints, a
/// categorical enum).
Map<String, Object?> toJson() => <String, Object?>{
'isCi': isCi,
'ciProvider': ciProvider,
'gradle': <String, Object?>{
'buildCacheEnabled': gradleBuildCacheEnabled,
'configurationCacheEnabled': gradleConfigurationCacheEnabled,
'parallelEnabled': gradleParallelEnabled,
'daemonEnabled': gradleDaemonEnabled,
'develocityDetected': gradleDevelocityDetected,
'initScriptCount': gradleInitScriptCount,
},
'ios': <String, Object?>{'ccacheAvailable': iosCcacheAvailable},
};
/// CI vendor → presence-indicator env var, ordered by specificity.
/// Presence-check (rather than value-compare) to stay robust against
/// case/quoting variation across vendors — e.g. Azure's `TF_BUILD`
/// is documented as "True" with a leading capital, GitHub's
/// `GITHUB_ACTIONS` as "true"; presence sidesteps the inconsistency.
///
/// Specific providers come first; the generic `CI` marker is last
/// so it only matches when no vendor-specific var is set. Order
/// among the specific providers doesn't matter — they're mutually
/// exclusive in practice.
static const _ciProviders = <(String envVar, String provider)>[
// https://docs.github.com/actions/learn-github-actions/variables#default-environment-variables
('GITHUB_ACTIONS', 'github'),
// https://docs.gitlab.com/ci/variables/predefined_variables/
('GITLAB_CI', 'gitlab'),
// https://circleci.com/docs/variables/
('CIRCLECI', 'circle'),
// https://devcenter.bitrise.io/en/references/available-environment-variables.html
('BITRISE_IO', 'bitrise'),
// https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables
('JENKINS_URL', 'jenkins'),
// https://buildkite.com/docs/pipelines/environment-variables
('BUILDKITE', 'buildkite'),
// https://learn.microsoft.com/azure/devops/pipelines/build/variables
('TF_BUILD', 'azure'),
// https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-env-vars.html
('CODEBUILD_BUILD_ID', 'codebuild'),
// https://support.atlassian.com/bitbucket-cloud/docs/variables-and-secrets/
('BITBUCKET_BUILD_NUMBER', 'bitbucket'),
// https://www.jetbrains.com/help/teamcity/predefined-build-parameters.html
('TEAMCITY_VERSION', 'teamcity'),
// https://docs.travis-ci.com/user/environment-variables#default-environment-variables
('TRAVIS', 'travis'),
// https://www.appveyor.com/docs/environment-variables/
('APPVEYOR', 'appveyor'),
// Generic CI indicator set by GitHub Actions, GitLab, CircleCI,
// Travis, Bitbucket, Buildkite, Drone, and others — catches the
// long tail without enumerating every vendor.
('CI', 'other'),
];
static String? _detectCiProvider(Map<String, String> env) {
for (final (envVar, provider) in _ciProviders) {
if (env[envVar] != null) return provider;
}
return null;
}
/// Reads a `key=value` properties file, returning empty when missing.
static Map<String, String> _readPropsFile(File? file) {
if (file == null || !file.existsSync()) return const <String, String>{};
final out = <String, String>{};
for (final line in file.readAsLinesSync()) {
final t = line.trim();
if (t.isEmpty || t.startsWith('#') || t.startsWith('!')) continue;
final eq = t.indexOf('=');
if (eq <= 0) continue;
out[t.substring(0, eq).trim()] = t.substring(eq + 1).trim();
}
return out;
}
/// Detect Develocity (or its predecessor "Gradle Enterprise") via
/// `settings.gradle{.kts}` plugin block or a user-global init script
/// referencing the plugin id. Best-effort substring match.
static bool _detectDevelocity(Directory? projectRoot, Directory? homeDir) {
bool fileMentionsDevelocity(File f) {
if (!f.existsSync()) return false;
final content = f.readAsStringSync();
return content.contains('com.gradle.develocity') ||
content.contains('com.gradle.enterprise') ||
content.contains('develocity {') ||
content.contains('gradleEnterprise {');
}
if (projectRoot != null) {
final candidates = <File>[
File(p.join(projectRoot.path, 'android', 'settings.gradle')),
File(p.join(projectRoot.path, 'android', 'settings.gradle.kts')),
];
for (final f in candidates) {
if (fileMentionsDevelocity(f)) return true;
}
}
if (homeDir != null) {
final initDir = Directory(p.join(homeDir.path, '.gradle', 'init.d'));
if (initDir.existsSync()) {
for (final entry in initDir.listSync()) {
if (entry is File &&
(entry.path.endsWith('.gradle') ||
entry.path.endsWith('.gradle.kts'))) {
if (fileMentionsDevelocity(entry)) return true;
}
}
}
}
return false;
}
static int _countInitScripts(Directory? homeDir) {
if (homeDir == null) return 0;
final initDir = Directory(p.join(homeDir.path, '.gradle', 'init.d'));
if (!initDir.existsSync()) return 0;
return initDir
.listSync()
.whereType<File>()
.where(
(f) => f.path.endsWith('.gradle') || f.path.endsWith('.gradle.kts'),
)
.length;
}
static bool _detectCcache(Map<String, String> env) {
final pathEnv = env['PATH'];
if (pathEnv == null) return false;
for (final dir in pathEnv.split(Platform.isWindows ? ';' : ':')) {
if (dir.isEmpty) continue;
final candidate = File(p.join(dir, 'ccache'));
if (candidate.existsSync()) return true;
}
return false;
}
}
@@ -0,0 +1,49 @@
import 'dart:io';
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/artifact_builder/build_trace_summary.dart';
/// Process-wide state for the build-trace feature. Populated by
/// `release_command` / `patch_command` at the start of a build so that
/// `ArtifactBuilder`, `AotTools`, and the HTTP client can emit events
/// into the same trace without threading a trace-file path through
/// every API.
class BuildTraceSession {
/// {@macro build_trace_session}
BuildTraceSession({required this.commandStartedAt});
/// The wall-clock time at which the current `shorebird` invocation began.
final DateTime commandStartedAt;
/// The Chrome Trace Event Format JSON file that producers
/// (`flutter build --shorebird-trace`, `aot_tools --trace`, and
/// shorebird_cli's own spans) append to. Null when tracing is not
/// supported on the pinned Flutter or hasn't been set up yet.
///
/// Set once by `ArtifactBuilder.prepareBuildTrace`; read by build
/// methods, `AotTools._exec`, and `ArtifactBuilder.writeBuildTraceSummary`.
File? traceFile;
/// Platform identifier ("android", "ios", "linux", "macos", "windows")
/// used to name the trace and summary files and to pick
/// platform-specific accumulators in the summary.
String? platform;
/// The [BuildTraceSummary] produced by the most recent
/// `writeBuildTraceSummary` call, or null if no summary was written
/// (unsupported Flutter pin, trace file malformed, etc.).
///
/// Read by `release_command.finalizeRelease` / `patch_command.createPatch`
/// to attach the summary to the outgoing metadata blob.
BuildTraceSummary? summary;
}
/// A reference to a [BuildTraceSession] instance. The default factory is
/// called at first read; `main()` overrides it with the real command start
/// time so ArtifactBuilder can read an accurate value.
final buildTraceSessionRef = create<BuildTraceSession>(
() => BuildTraceSession(commandStartedAt: DateTime.now()),
);
/// The [BuildTraceSession] instance available in the current zone.
BuildTraceSession get buildTraceSession => read(buildTraceSessionRef);
@@ -0,0 +1,776 @@
// This file is the consumer end of a string-level contract shared with
// the trace producers (flutter_tools, aot_tools, the Gradle init
// script, the CocoaPods wrapper). Event category names, gradle task
// `kind` values, and span-name prefixes live in
// `shorebird_build_trace`'s `TraceSchema`; if you add a new bucket
// here, add the matching producer-side constant there.
import 'dart:convert';
import 'dart:io';
import 'package:shorebird_build_trace/shorebird_build_trace.dart';
import 'package:shorebird_cli/src/artifact_builder/build_environment.dart';
import 'package:shorebird_cli/src/artifact_builder/duration_distribution.dart';
/// Summary of a Chrome Trace Event Format build trace.
///
/// Aggregate [Duration] timings and small integer counters, suitable
/// for uploading to Shorebird's servers as part of release telemetry.
class BuildTraceSummary {
/// Creates a [BuildTraceSummary] directly from pre-computed fields.
/// Most callers should use [BuildTraceSummary.fromEvents] or
/// [BuildTraceSummary.tryFromFile].
BuildTraceSummary({
required this.platform,
required this.total,
required this.flutterBuild,
required this.shorebirdOverhead,
required this.network,
required this.dart,
required this.flutterAssemble,
required this.native,
required this.flutterTool,
this.android,
this.ios,
this.environment,
});
/// Build a summary from the raw list of trace events written by Flutter
/// (and merged with Shorebird-side events).
///
/// [platform] is `android` or `ios`. Platform-specific stats ([android] /
/// [ios]) are only populated for the matching platform.
/// [shorebirdOverhead] captures Shorebird's own wall-clock time around
/// `flutter build` — null when the caller can't compute it.
factory BuildTraceSummary.fromEvents(
List<Map<String, Object?>> events, {
required String platform,
Duration? shorebirdOverhead,
BuildEnvironment? environment,
}) {
final acc = _Accumulator();
for (final e in events) {
// Skip metadata (`ph: "M"`) and flow (`ph: "s"`/`"f"`) events —
// they don't carry a duration to bucket, they just label the
// producer / draw causality arrows in Perfetto.
if (e['ph'] != 'X') continue;
_processEvent(acc, e);
}
return _buildSummary(
acc,
platform: platform,
shorebirdOverhead: shorebirdOverhead,
environment: environment,
);
}
/// Dispatches a single `ph:"X"` event into the right accumulator
/// bucket based on its `cat` and (for ambiguous cats) its `name`.
/// Unknown categories fall through via [TraceCategory.unknown]; the
/// switch stays exhaustive.
static void _processEvent(_Accumulator acc, Map<String, Object?> e) {
final dur = Duration(microseconds: (e['dur'] as num?)?.toInt() ?? 0);
final name = (e['name'] as String?) ?? '';
final args =
(e['args'] as Map<Object?, Object?>?) ?? const <Object?, Object?>{};
switch (TraceCategory.parse(e['cat'] as String?)) {
case TraceCategory.flutter:
_processFlutterEvent(acc, name: name, dur: dur);
case TraceCategory.subprocess:
_processSubprocessEvent(acc, name: name, dur: dur);
case TraceCategory.gradle:
case TraceCategory.xcode:
acc.nativeBuild += dur;
case TraceCategory.assemble:
acc.assembleCount++;
if (args['skipped'] == true) acc.skippedAssembleCount++;
acc.assembleCategory.add(_categorize(name), dur);
case TraceCategory.gradleTask:
_processGradleTaskEvent(acc, dur: dur, args: args);
case TraceCategory.xcodeSubsection:
acc.xcodeSubsectionDurations.add(dur);
case TraceCategory.network:
acc.network += dur;
acc.networkCount++;
case TraceCategory.unknown:
// Future producer version emitted a category we don't know.
// Dropped on purpose — bucketing it anywhere else would lie.
break;
}
}
static void _processFlutterEvent(
_Accumulator acc, {
required String name,
required Duration dur,
}) {
// Flutter emits exactly one `flutter build <target>` umbrella span
// per invocation and zero-or-more flutter-tool sub-spans
// (pre-build setup, post-build processing). `+=` works for both
// since "exactly one" is a special case of "sum".
if (name.startsWith(TraceNames.flutterBuildSpanPrefix)) {
acc.flutterBuild += dur;
} else {
acc.flutterTool += dur;
}
}
static void _processSubprocessEvent(
_Accumulator acc, {
required String name,
required Duration dur,
}) {
const prefix = '${TraceNames.podInstallNamePrefix}: ';
if (name == TraceNames.podInstallSpanName) {
acc.podInstall = acc.podInstall + dur;
} else if (name.startsWith(prefix)) {
acc.podPhase.add(
PodInstallPhase.parse(name.substring(prefix.length)),
dur,
);
}
}
static void _processGradleTaskEvent(
_Accumulator acc, {
required Duration dur,
required Map<Object?, Object?> args,
}) {
acc.gradleTaskDurations.add(dur);
// Per-task cache outcome from the init script. Mutually exclusive
// in practice: a task either ran, was up-to-date (incremental
// skip), or was restored from the build cache.
if (args['fromCache'] == true) {
acc.gradleTaskFromCacheCount++;
} else if (args['upToDate'] == true) {
acc.gradleTaskUpToDateCount++;
} else {
acc.gradleTaskExecutedCount++;
}
acc.gradleKind.add(
GradleTaskKind.parse(args['kind'] as String?),
dur,
);
}
static BuildTraceSummary _buildSummary(
_Accumulator acc, {
required String platform,
Duration? shorebirdOverhead,
BuildEnvironment? environment,
}) {
final flutterBuild = acc.flutterBuild;
return BuildTraceSummary(
platform: platform,
total: flutterBuild + (shorebirdOverhead ?? Duration.zero),
flutterBuild: flutterBuild,
shorebirdOverhead: shorebirdOverhead,
network: NetworkStats(
duration: acc.network,
callCount: acc.networkCount,
),
dart: _dartStats(acc),
flutterAssemble: _flutterAssembleStats(acc),
native: _nativeStats(acc),
flutterTool: acc.flutterTool,
android: platform == 'android' ? _androidStats(acc) : null,
ios: platform == 'ios' ? _iosStats(acc) : null,
environment: environment,
);
}
static DartStats _dartStats(_Accumulator acc) {
final kernel = acc.assembleCategory.of(_AssembleCategory.kernelSnapshot);
final gen = acc.assembleCategory.of(_AssembleCategory.genSnapshot);
return DartStats(
total: kernel + gen,
kernelSnapshot: kernel,
genSnapshot: gen,
build: acc.assembleCategory.of(_AssembleCategory.dartBuild),
);
}
static FlutterAssembleStats _flutterAssembleStats(_Accumulator acc) {
return FlutterAssembleStats(
assets: acc.assembleCategory.of(_AssembleCategory.assets),
codegen: acc.assembleCategory.of(_AssembleCategory.codegen),
other: acc.assembleCategory.of(_AssembleCategory.other),
targetCount: acc.assembleCount,
skippedCount: acc.skippedAssembleCount,
);
}
static NativeBuildStats _nativeStats(_Accumulator acc) {
// "Native compile only" = native outer minus everything flutter
// assemble reported running inside it. Clamped at 0 because the
// sum can exceed nativeBuild in edge cases.
final assembleTotal = acc.assembleCategory.values.fold(
Duration.zero,
(a, b) => a + b,
);
final rawNativeCompile = acc.nativeBuild - assembleTotal;
final nativeCompile = rawNativeCompile < Duration.zero
? Duration.zero
: rawNativeCompile;
return NativeBuildStats(
build: acc.nativeBuild,
compile: nativeCompile,
);
}
static AndroidStats _androidStats(_Accumulator acc) {
Duration kindDur(GradleTaskKind k) => acc.gradleKind.of(k);
return AndroidStats(
gradle: GradleStats(
taskDistribution: DurationDistribution.fromDurations(
acc.gradleTaskDurations,
),
taskFromCacheCount: acc.gradleTaskFromCacheCount,
taskUpToDateCount: acc.gradleTaskUpToDateCount,
taskExecutedCount: acc.gradleTaskExecutedCount,
kotlinCompile: kindDur(GradleTaskKind.kotlinCompile),
javaCompile: kindDur(GradleTaskKind.javaCompile),
dex: kindDur(GradleTaskKind.dex),
resources: kindDur(GradleTaskKind.resources),
transform: kindDur(GradleTaskKind.transform),
r8Minify: kindDur(GradleTaskKind.r8Minify),
lint: kindDur(GradleTaskKind.lint),
flutterGradlePlugin: kindDur(GradleTaskKind.flutterGradlePlugin),
bundle: kindDur(GradleTaskKind.bundle),
packaging: kindDur(GradleTaskKind.packaging),
aidl: kindDur(GradleTaskKind.aidl),
nativeLink: kindDur(GradleTaskKind.nativeLink),
gradleScaffold: kindDur(GradleTaskKind.gradleScaffold),
),
);
}
static IosStats _iosStats(_Accumulator acc) {
Duration phaseDur(PodInstallPhase p) => acc.podPhase.of(p);
return IosStats(
podInstall: PodInstallStats(
duration: acc.podInstall,
analyze: phaseDur(PodInstallPhase.analyzing),
download: phaseDur(PodInstallPhase.downloading),
generate: phaseDur(PodInstallPhase.generating),
integrate: phaseDur(PodInstallPhase.integrating),
),
xcode: XcodeStats(
subsectionDistribution: DurationDistribution.fromDurations(
acc.xcodeSubsectionDurations,
),
),
);
}
/// Parse [traceFile] and return a summary, or null if the file is missing
/// or can't be parsed as a Chrome Trace Event Format JSON array.
static BuildTraceSummary? tryFromFile(
File traceFile, {
required String platform,
Duration? shorebirdOverhead,
BuildEnvironment? environment,
}) {
final events = tryReadEvents(traceFile);
if (events == null) return null;
return BuildTraceSummary.fromEvents(
events,
platform: platform,
shorebirdOverhead: shorebirdOverhead,
environment: environment,
);
}
/// Parse [traceFile] once as a Chrome Trace Event Format JSON array and
/// return the raw event list. Returns null if the file is missing or
/// malformed. Callers that need to build more than one summary from the
/// same trace (e.g. once to measure flutter wall clock, again with
/// Shorebird overhead computed from it) should parse once and pass the
/// list to [fromEvents] — parsing a multi-megabyte trace twice is wasted
/// work on plugin-heavy apps.
static List<Map<String, Object?>>? tryReadEvents(File traceFile) {
if (!traceFile.existsSync()) return null;
try {
final decoded = jsonDecode(traceFile.readAsStringSync());
final list = decoded is List
? decoded
: (decoded is Map ? decoded['traceEvents'] as List? : null);
if (list == null) return null;
return list.cast<Map<String, Object?>>();
} on FormatException {
return null;
}
}
static _AssembleCategory _categorize(String name) {
final n = name.toLowerCase();
if (n.contains('kernel_snapshot') || n == 'kernel') {
return _AssembleCategory.kernelSnapshot;
}
if (n.startsWith('android_aot') ||
n.contains('aot_assembly') ||
n.contains('aot_elf') ||
n == 'ios_aot' ||
n.contains('aot_bundle')) {
return _AssembleCategory.genSnapshot;
}
if (n == 'dart_build') {
return _AssembleCategory.dartBuild;
}
if (n.startsWith('gen_')) {
return _AssembleCategory.codegen;
}
if (n.contains('asset_bundle') ||
n.contains('bundle_flutter_assets') ||
n.contains('install_code_assets') ||
n.contains('unpack') ||
n.contains('copy_framework') ||
n.contains('asset')) {
return _AssembleCategory.assets;
}
return _AssembleCategory.other;
}
/// `android` or `ios`.
final String platform;
/// Total command wall-clock (Flutter build + Shorebird overhead).
final Duration total;
/// Flutter's own reported wall-clock duration (the `flutter build *`
/// umbrella event in the trace).
final Duration flutterBuild;
/// Wall-clock time the Shorebird CLI spent around Flutter. Null when
/// the caller couldn't compute it.
final Duration? shorebirdOverhead;
/// Network I/O time and request counts, summed across Shorebird-side
/// HTTP (auth, artifact upload) and Flutter-side HTTP (artifact
/// downloads when the cache is cold).
final NetworkStats network;
/// Dart-compilation breakdown (kernel snapshot + gen_snapshot) plus the
/// `dart_build` user script target, which is tracked separately.
final DartStats dart;
/// Flutter assemble sub-invocation durations by bucket.
final FlutterAssembleStats flutterAssemble;
/// Native (Gradle/Xcode) outer span and derived "native-only compile"
/// approximation.
final NativeBuildStats native;
/// Time in the flutter tool itself (pre/post setup), excluding the
/// umbrella span.
final Duration flutterTool;
/// Android-specific stats. Only non-null when `platform == 'android'`.
final AndroidStats? android;
/// iOS-specific stats. Only non-null when `platform == 'ios'`.
final IosStats? ios;
/// Build-environment snapshot — caching configuration, CI provider,
/// etc. Lets us tell apart "slow because nothing's configured" from
/// "slow despite caching being on" in field data.
final BuildEnvironment? environment;
/// Shorebird CLI's wall-clock time around Flutter with network I/O
/// subtracted — i.e. what Shorebird spent doing local work (file I/O,
/// hashing, archive assembly, aot_tools link/gen_snapshot bookkeeping
/// outside their own spans). Null when [shorebirdOverhead] is null.
///
/// Clamped to zero if the network tally exceeds overhead (can happen
/// when flutter's own downloads are counted in network but executed
/// inside the flutterBuild span, which is already subtracted from
/// overhead).
Duration? get shorebirdLocal {
final overhead = shorebirdOverhead;
if (overhead == null) return null;
final local = overhead - network.duration;
return local.isNegative ? Duration.zero : local;
}
/// JSON representation suitable for writing alongside the raw trace.
/// Field names are stable and safe to upload — no paths or identifiers.
/// Platform-specific sections are omitted (not nulled) on the other
/// platform.
///
/// `dart`'s total lives inside the `dart` sub-object (`dart.totalMs`);
/// no redundant top-level `dartMs`. `nonDart` is a consumer-side
/// subtraction (`flutterBuildMs - dart.totalMs`) — kept out of the
/// on-wire shape so there's exactly one way to read each value.
Map<String, Object?> toJson() => <String, Object?>{
'version': 8,
'platform': platform,
'totalMs': total.inMilliseconds,
'flutterBuildMs': flutterBuild.inMilliseconds,
'shorebirdOverheadMs': shorebirdOverhead?.inMilliseconds,
'shorebirdLocalMs': shorebirdLocal?.inMilliseconds,
'network': network.toJson(),
'dart': dart.toJson(),
'flutterAssemble': flutterAssemble.toJson(),
'native': native.toJson(),
'flutterTool': <String, Object?>{'ms': flutterTool.inMilliseconds},
'android': ?android?.toJson(),
'ios': ?ios?.toJson(),
'environment': ?environment?.toJson(),
};
}
/// Network I/O totals. Combined across Shorebird-side (auth, artifact
/// upload, etc.) and Flutter-side (artifact downloads) HTTP.
class NetworkStats {
/// Creates a [NetworkStats].
NetworkStats({required this.duration, required this.callCount});
/// Total time across all HTTP requests.
final Duration duration;
/// Number of HTTP requests.
final int callCount;
/// JSON form.
Map<String, Object?> toJson() => {
'ms': duration.inMilliseconds,
'callCount': callCount,
};
}
/// Dart compilation: source → kernel, kernel → native AOT, plus `dart_build`
/// user-script execution.
class DartStats {
/// Creates a [DartStats].
DartStats({
required this.total,
required this.kernelSnapshot,
required this.genSnapshot,
required this.build,
});
/// `kernelSnapshot + genSnapshot` — the pure Dart-compile total.
final Duration total;
/// Dart frontend (source → kernel `.dill`).
final Duration kernelSnapshot;
/// gen_snapshot AOT (kernel → native code), summed across architectures.
final Duration genSnapshot;
/// `dart_build` target — runs user-authored `build.dart` scripts.
/// Reported separately because it's Dart work, but not *compilation*.
final Duration build;
/// JSON form.
Map<String, Object?> toJson() => {
'totalMs': total.inMilliseconds,
'kernelSnapshotMs': kernelSnapshot.inMilliseconds,
'genSnapshotMs': genSnapshot.inMilliseconds,
'buildMs': build.inMilliseconds,
};
}
/// Flutter-assemble internal targets excluding the dart-compile ones.
class FlutterAssembleStats {
/// Creates a [FlutterAssembleStats].
FlutterAssembleStats({
required this.assets,
required this.codegen,
required this.other,
required this.targetCount,
required this.skippedCount,
});
/// Asset bundling and framework unpacking.
final Duration assets;
/// `gen_*` code generation.
final Duration codegen;
/// Residual assemble targets not matching any other bucket.
final Duration other;
/// Total flutter assemble targets that appeared in the trace.
final int targetCount;
/// Targets that reported `skipped: true` (cache hits).
final int skippedCount;
/// JSON form.
Map<String, Object?> toJson() => {
'assetsMs': assets.inMilliseconds,
'codegenMs': codegen.inMilliseconds,
'otherMs': other.inMilliseconds,
'targetCount': targetCount,
'skippedCount': skippedCount,
};
}
/// Native toolchain outer span + derived "pure native compile" estimate.
class NativeBuildStats {
/// Creates a [NativeBuildStats].
NativeBuildStats({required this.build, required this.compile});
/// Gradle (Android) or Xcode (iOS) outer span duration.
final Duration build;
/// Upper-bound for time a Flutter-aware native build cache could save:
/// `build` minus every flutter assemble target summed together.
final Duration compile;
/// JSON form.
Map<String, Object?> toJson() => {
'buildMs': build.inMilliseconds,
'compileMs': compile.inMilliseconds,
};
}
/// Platform-specific Android stats.
class AndroidStats {
/// Creates an [AndroidStats].
AndroidStats({required this.gradle});
/// Per-task Gradle breakdown.
final GradleStats gradle;
/// JSON form.
Map<String, Object?> toJson() => {'gradle': gradle.toJson()};
}
/// Gradle task histogram + per-kind totals. Populated from the
/// `shorebird_trace_init.gradle` TaskExecutionListener.
class GradleStats {
/// Creates a [GradleStats].
GradleStats({
required this.taskDistribution,
required this.taskFromCacheCount,
required this.taskUpToDateCount,
required this.taskExecutedCount,
required this.kotlinCompile,
required this.javaCompile,
required this.dex,
required this.resources,
required this.transform,
required this.r8Minify,
required this.lint,
required this.flutterGradlePlugin,
required this.bundle,
required this.packaging,
required this.aidl,
required this.nativeLink,
required this.gradleScaffold,
});
/// Distribution of per-task durations (count, sum, p50, p90, max).
/// Sum is typically much larger than gradle wall clock because Gradle
/// runs tasks in parallel.
final DurationDistribution taskDistribution;
/// Tasks restored from Gradle's build cache (FROM-CACHE skip message).
/// Non-zero indicates `org.gradle.caching=true` is doing real work.
final int taskFromCacheCount;
/// Tasks that Gradle saw as up-to-date and skipped without running.
/// Incremental-build hits.
final int taskUpToDateCount;
/// Tasks that actually executed (cache miss + not up-to-date).
final int taskExecutedCount;
/// Kotlin compilation time across plugins.
final Duration kotlinCompile;
/// Java compilation time across plugins.
final Duration javaCompile;
/// Dex (D8/R8 output) time.
final Duration dex;
/// Resource merging / processing time.
final Duration resources;
/// AAR / jetifier / desugar transform time.
final Duration transform;
/// R8 / minify / shrinking. Often the single slowest task on release
/// builds.
final Duration r8Minify;
/// Android lint (`lintVitalAnalyzeRelease` etc.). AGP runs these on
/// every release build by default and they can dominate.
final Duration lint;
/// The Flutter Gradle plugin's own orchestration tasks (e.g.
/// `compileFlutterBuildRelease`).
final Duration flutterGradlePlugin;
/// Bundle-related Gradle tasks (AAB packaging etc.).
final Duration bundle;
/// APK packaging tasks.
final Duration packaging;
/// AIDL interface compilation.
final Duration aidl;
/// Merging / linking native libraries.
final Duration nativeLink;
/// Per-plugin scaffolding — AAR metadata, proguard rule export,
/// validate/check tasks, misc. `prepare*` / `copy*` / `generate*` not
/// claimed by a more specific bucket.
final Duration gradleScaffold;
/// JSON form.
Map<String, Object?> toJson() => <String, Object?>{
'taskDistribution': taskDistribution.toJson(),
'taskFromCacheCount': taskFromCacheCount,
'taskUpToDateCount': taskUpToDateCount,
'taskExecutedCount': taskExecutedCount,
'kotlinCompileMs': kotlinCompile.inMilliseconds,
'javaCompileMs': javaCompile.inMilliseconds,
'dexMs': dex.inMilliseconds,
'resourcesMs': resources.inMilliseconds,
'transformMs': transform.inMilliseconds,
'r8MinifyMs': r8Minify.inMilliseconds,
'lintMs': lint.inMilliseconds,
'flutterGradlePluginMs': flutterGradlePlugin.inMilliseconds,
'bundleMs': bundle.inMilliseconds,
'packagingMs': packaging.inMilliseconds,
'aidlMs': aidl.inMilliseconds,
'nativeLinkMs': nativeLink.inMilliseconds,
'gradleScaffoldMs': gradleScaffold.inMilliseconds,
};
}
/// Platform-specific iOS stats.
class IosStats {
/// Creates an [IosStats].
IosStats({required this.podInstall, required this.xcode});
/// CocoaPods `pod install` timing, split into phases.
final PodInstallStats podInstall;
/// Xcode per-phase breakdown from `-showBuildTimingSummary`.
final XcodeStats xcode;
/// JSON form.
Map<String, Object?> toJson() => {
'podInstall': podInstall.toJson(),
'xcode': xcode.toJson(),
};
}
/// CocoaPods `pod install` timing, split into phases parsed from the
/// `--verbose` output: analyze, download, generate project, integrate.
class PodInstallStats {
/// Creates a [PodInstallStats].
PodInstallStats({
required this.duration,
required this.analyze,
required this.download,
required this.generate,
required this.integrate,
});
/// Total `pod install` wall-clock time.
final Duration duration;
/// Dependency analysis phase.
final Duration analyze;
/// Downloading pods / dependencies phase.
final Duration download;
/// Generating the Pods Xcode project phase.
final Duration generate;
/// Integrating pods into the client Xcode project phase.
final Duration integrate;
/// JSON form.
Map<String, Object?> toJson() => {
'ms': duration.inMilliseconds,
'analyzeMs': analyze.inMilliseconds,
'downloadMs': download.inMilliseconds,
'generateMs': generate.inMilliseconds,
'integrateMs': integrate.inMilliseconds,
};
}
/// Xcode per-phase totals parsed from the `-showBuildTimingSummary` block.
/// Xcode per-subsection aggregates from the structured build log emitted
/// by `xcrun xcresulttool get log --type build`. Each top-level
/// subsection is a target or build action ("Build target X", "Archive
/// target Y", "Compile Swift module Z", ...); subsection titles are
/// high-variance and potentially identifying, so we keep a histogram
/// rather than per-title totals.
class XcodeStats {
/// Creates an [XcodeStats].
XcodeStats({required this.subsectionDistribution});
/// Distribution of per-subsection durations (count, sum, p50, p90,
/// max). Sum is often much larger than the `xcode archive` wall clock
/// because Xcode runs targets in parallel.
final DurationDistribution subsectionDistribution;
/// JSON form.
Map<String, Object?> toJson() => {
'subsectionDistribution': subsectionDistribution.toJson(),
};
}
enum _AssembleCategory {
kernelSnapshot,
genSnapshot,
dartBuild,
assets,
codegen,
other,
}
/// Mutable scratch struct that [BuildTraceSummary.fromEvents] fills
/// while iterating the event list, then [_buildSummary] consumes. Its
/// only job is to carry typed counters without needing ~30 positional
/// arguments between the per-event handlers.
class _Accumulator {
Duration flutterBuild = Duration.zero;
Duration flutterTool = Duration.zero;
Duration nativeBuild = Duration.zero;
int assembleCount = 0;
int skippedAssembleCount = 0;
Duration network = Duration.zero;
int networkCount = 0;
Duration podInstall = Duration.zero;
/// Per-bucket totals, keyed by the enum/category that classified the
/// event. Read via [Map.operator[]] with a null-coalesce to
/// [Duration.zero] — unpopulated buckets (e.g. gradle kinds on an iOS
/// build) never allocate an entry.
final assembleCategory = <_AssembleCategory, Duration>{};
final gradleKind = <GradleTaskKind, Duration>{};
final podPhase = <PodInstallPhase, Duration>{};
int gradleTaskFromCacheCount = 0;
int gradleTaskUpToDateCount = 0;
int gradleTaskExecutedCount = 0;
final gradleTaskDurations = <Duration>[];
// Xcode subsection titles are high-variance ("Build target <name>",
// "Archive target <name>", etc.) so the summary keeps aggregates and
// a histogram rather than name-keyed totals.
final xcodeSubsectionDurations = <Duration>[];
}
extension on Map<dynamic, Duration> {
/// Adds [value] to the counter keyed by [key], initializing from
/// [Duration.zero].
void add<K>(K key, Duration value) {
this[key] = (this[key] ?? Duration.zero) + value;
}
/// Reads the counter keyed by [key], returning [Duration.zero] if
/// unset. Sugar for `map[key] ?? Duration.zero` that keeps
/// [_buildSummary]'s field list flat.
Duration of<K>(K key) => this[key] ?? Duration.zero;
}
@@ -0,0 +1,70 @@
/// Summary statistics over a list of Durations — count, total, p50,
/// p90, and max. Produced by [DurationDistribution.fromDurations];
/// serialized as a nested object in the build-trace summary JSON
/// under keys `{count, sumMs, p50Ms, p90Ms, maxMs}`.
class DurationDistribution {
/// Creates a [DurationDistribution] directly from precomputed fields.
/// Most callers should use [DurationDistribution.fromDurations] or
/// [DurationDistribution.empty].
DurationDistribution({
required this.count,
required this.sum,
required this.p50,
required this.p90,
required this.max,
});
/// Empty distribution: count 0, all durations [Duration.zero].
factory DurationDistribution.empty() => DurationDistribution(
count: 0,
sum: Duration.zero,
p50: Duration.zero,
p90: Duration.zero,
max: Duration.zero,
);
/// Computes a distribution from a list of Durations. Empty input
/// returns [DurationDistribution.empty]. Durations implement
/// Comparable, so we sort in place (well, on a copy) to pick
/// percentiles.
factory DurationDistribution.fromDurations(List<Duration> values) {
if (values.isEmpty) return DurationDistribution.empty();
final sorted = [...values]..sort();
Duration at(double q) {
final idx = (sorted.length * q).floor().clamp(0, sorted.length - 1);
return sorted[idx];
}
return DurationDistribution(
count: sorted.length,
sum: sorted.fold(Duration.zero, (a, b) => a + b),
p50: at(0.5),
p90: at(0.9),
max: sorted.last,
);
}
/// Number of samples in the distribution.
final int count;
/// Sum of all samples.
final Duration sum;
/// Median (50th percentile) of the samples.
final Duration p50;
/// 90th percentile of the samples.
final Duration p90;
/// Maximum sample.
final Duration max;
/// JSON form.
Map<String, Object?> toJson() => <String, Object?>{
'count': count,
'sumMs': sum.inMilliseconds,
'p50Ms': p50.inMilliseconds,
'p90Ms': p90.inMilliseconds,
'maxMs': max.inMilliseconds,
};
}
@@ -0,0 +1,116 @@
import 'dart:convert';
import 'dart:io';
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_build_trace/shorebird_build_trace.dart';
export 'package:shorebird_build_trace/shorebird_build_trace.dart'
show currentProcessId, BuildTraceEvent, BuildTracer, PhaseTracker;
/// Perfetto row id for network (HTTP) spans within the shorebird_cli
/// process. Local tid; no cross-repo coordination.
const int _networkTid = 1;
/// Perfetto row id for shorebird_cli's own command-level phase spans
/// recorded via [ShorebirdTracer.span].
const int _shorebirdTid = 2;
/// Shorebird-specific wrapper around [BuildTracer]. Owns the pid +
/// tid layout for shorebird_cli's rows (network + shorebird_cli), adds
/// [span]/[addNetworkEvent]/[mergeInto] helpers keyed off that layout,
/// and emits `process_name` / `thread_name` metadata when merging into
/// Flutter's trace file.
///
/// For generic helpers (trace/timeSubprocess/recordNetworkSpan etc.)
/// see [BuildTracer] directly — this class is the shorebird_cli-shaped
/// facade, not a wire-format reimplementation.
class ShorebirdTracer {
/// The underlying [BuildTracer] that holds the raw events.
final BuildTracer _tracer = BuildTracer();
/// Real pid of the shorebird_cli process — captured at construction
/// so every event emitted through this tracer is tagged with it.
final int _pid = currentProcessId();
/// Raw event buffer, for tests that need to inspect individual spans.
List<Map<String, Object?>> get events => _tracer.events;
/// Record a completed network span on the shorebird_cli row.
void addNetworkEvent({
required String name,
required DateTime start,
required Duration duration,
Map<String, Object?>? args,
}) {
_tracer.addCompleteEvent(
name: name,
cat: 'network',
pid: _pid,
tid: _networkTid,
start: start,
end: start.add(duration),
args: args,
);
}
/// Run [body], time it, and record a span on the shorebird_cli row.
/// Matches [BuildTracer.traceAsync] semantics but pre-fills pid/tid
/// so commands don't have to know the layout.
Future<T> span<T>({
required String name,
required String category,
required Future<T> Function() body,
Map<String, Object?>? args,
}) => _tracer.traceAsync<T>(
name: name,
cat: category,
pid: _pid,
tid: _shorebirdTid,
body: body,
args: args,
);
/// Emits a flow-start event at [at] with id = [id]. Shorebird
/// convention uses the child process's real pid as the flow id so
/// the child emits the matching `ph: "f"` with the same id without
/// any plumbing.
void addSpawnFlowStart({
required int id,
required DateTime at,
int fromTid = _shorebirdTid,
}) {
_tracer.addFlowStart(id: id, pid: _pid, tid: fromTid, at: at);
}
/// Append accumulated events to [traceFile] (a Chrome Trace Event
/// Format JSON array, as written by Flutter). Also emits our
/// process_name / thread_name metadata so Perfetto labels our rows.
/// No-op if the file doesn't exist or isn't a JSON array.
void mergeInto(File traceFile) {
if (!traceFile.existsSync()) return;
final List<Map<String, Object?>> existingEvents;
try {
final decoded = jsonDecode(traceFile.readAsStringSync());
if (decoded is! List) return;
existingEvents = decoded.whereType<Map<String, Object?>>().toList();
} on FormatException {
return;
}
_tracer
..addProcessNameMetadata(pid: _pid, name: 'shorebird_cli')
..addThreadNameMetadata(pid: _pid, tid: _networkTid, name: 'network')
..addThreadNameMetadata(
pid: _pid,
tid: _shorebirdTid,
name: 'shorebird_cli',
)
..writeToFile(traceFile, existingEvents: existingEvents);
}
}
/// A reference to a [ShorebirdTracer] instance. One instance per `shorebird`
/// invocation, seeded in `main()`.
final shorebirdTracerRef = create<ShorebirdTracer>(ShorebirdTracer.new);
/// The [ShorebirdTracer] instance available in the current zone.
ShorebirdTracer get shorebirdTracer => read(shorebirdTracerRef);
@@ -5,6 +5,7 @@ import 'package:path/path.dart' as p;
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:scoped_deps/scoped_deps.dart'; import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_builder/build_trace_session.dart';
import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/cache.dart'; import 'package:shorebird_cli/src/cache.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
@@ -493,6 +494,14 @@ Building with Flutter $flutterVersionString to determine the release version...
() async { () async {
await cache.updateAll(); await cache.updateAll();
// Set up build tracing before any flutter build / aot_tools /
// gen_snapshot call runs. Version-gated inside prepareBuildTrace —
// a no-op on older Flutter pins. Summary is written at the very
// end of createPatch, after aot_tools link and artifact uploads.
await artifactBuilder.prepareBuildTrace(
platform: patcher.releaseType.releasePlatform.name,
);
// Don't built the patch artifact twice with the same Flutter revision. // Don't built the patch artifact twice with the same Flutter revision.
if (lastBuiltFlutterRevision != release.flutterRevision) { if (lastBuiltFlutterRevision != release.flutterRevision) {
final flutterVersionString = await shorebirdFlutter final flutterVersionString = await shorebirdFlutter
@@ -535,6 +544,12 @@ Building patch with Flutter $flutterVersionString
patchArtifactBundles: patchArtifactBundles, patchArtifactBundles: patchArtifactBundles,
); );
// Write the build-trace summary after all compile/link work has
// finished — the metadata upload is the last step and it carries
// this summary, so we finalize immediately before it. No-op when
// tracing wasn't set up (older Flutter pin).
artifactBuilder.writeBuildTraceSummary();
final baseMetadata = CreatePatchMetadata( final baseMetadata = CreatePatchMetadata(
releasePlatform: patcher.releaseType.releasePlatform, releasePlatform: patcher.releaseType.releasePlatform,
usedIgnoreAssetChangesFlag: allowAssetDiffs, usedIgnoreAssetChangesFlag: allowAssetDiffs,
@@ -554,6 +569,11 @@ Building patch with Flutter $flutterVersionString
usesShorebirdCodePushPackage: usesShorebirdCodePushPackage:
shorebirdEnv.usesShorebirdCodePushPackage, shorebirdEnv.usesShorebirdCodePushPackage,
), ),
// Attach the build-trace summary if the build produced one.
// Null for older Flutter pins without the --shorebird-trace
// flag or when trace parsing failed; uploader sends
// null-as-omitted.
buildTraceSummary: buildTraceSession.summary?.toJson(),
); );
final updateMetadata = await patcher.updatedCreatePatchMetadata( final updateMetadata = await patcher.updatedCreatePatchMetadata(
baseMetadata, baseMetadata,
@@ -5,6 +5,7 @@ import 'package:mason_logger/mason_logger.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:scoped_deps/scoped_deps.dart'; import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_builder/build_trace_session.dart';
import 'package:shorebird_cli/src/cache.dart'; import 'package:shorebird_cli/src/cache.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/release/release.dart'; import 'package:shorebird_cli/src/commands/release/release.dart';
@@ -307,6 +308,15 @@ of the iOS app that is using this module. (aar and ios-framework only)''',
() async { () async {
await cache.updateAll(); await cache.updateAll();
// Set up build tracing for this platform before any flutter build /
// aot_tools / gen_snapshot call runs. Version-gated inside
// prepareBuildTrace — a no-op on older Flutter pins. Finalized at
// the end of finalizeRelease, after upload, so uploaded metadata
// reflects the whole command.
await artifactBuilder.prepareBuildTrace(
platform: releaser.releaseType.releasePlatform.name,
);
final flutterVersionString = await shorebirdFlutter final flutterVersionString = await shorebirdFlutter
.getVersionAndRevision(); .getVersionAndRevision();
logger.info( logger.info(
@@ -582,6 +592,11 @@ ${summary.join('\n')}
required Release release, required Release release,
required Releaser releaser, required Releaser releaser,
}) async { }) async {
// Write the build-trace summary now, after the release artifact has been
// uploaded, so aggregate timings reflect the full command. No-op when
// tracing wasn't set up (older Flutter pin).
artifactBuilder.writeBuildTraceSummary();
final hasPublicKey = final hasPublicKey =
results.wasParsed(CommonArguments.publicKeyArg.name) || results.wasParsed(CommonArguments.publicKeyArg.name) ||
results.wasParsed(CommonArguments.publicKeyCmd.name); results.wasParsed(CommonArguments.publicKeyCmd.name);
@@ -597,6 +612,10 @@ ${summary.join('\n')}
shorebirdYaml: shorebirdEnv.getShorebirdYaml()!, shorebirdYaml: shorebirdEnv.getShorebirdYaml()!,
usesShorebirdCodePushPackage: shorebirdEnv.usesShorebirdCodePushPackage, usesShorebirdCodePushPackage: shorebirdEnv.usesShorebirdCodePushPackage,
), ),
// Attach the build-trace summary if the build produced one.
// Null for older Flutter pins without the --shorebird-trace flag
// or when trace parsing failed; uploader sends null-as-omitted.
buildTraceSummary: buildTraceSession.summary?.toJson(),
); );
final updatedMetadata = await releaser.updatedReleaseMetadata(baseMetadata); final updatedMetadata = await releaser.updatedReleaseMetadata(baseMetadata);
await codePushClientWrapper.updateReleaseStatus( await codePushClientWrapper.updateReleaseStatus(
@@ -7,6 +7,7 @@ import 'package:meta/meta.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:pub_semver/pub_semver.dart'; import 'package:pub_semver/pub_semver.dart';
import 'package:scoped_deps/scoped_deps.dart'; import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/artifact_builder/build_trace_session.dart';
import 'package:shorebird_cli/src/cache.dart'; import 'package:shorebird_cli/src/cache.dart';
import 'package:shorebird_cli/src/engine_config.dart'; import 'package:shorebird_cli/src/engine_config.dart';
import 'package:shorebird_cli/src/extensions/version.dart'; import 'package:shorebird_cli/src/extensions/version.dart';
@@ -129,6 +130,17 @@ class AotTools {
}) async { }) async {
await cache.updateAll(); await cache.updateAll();
// Thread the build-trace file through to aot_tools via its global
// --trace flag so its subprocess spans (and sub-subprocess spans like
// gen_snapshot invoked by the linker) merge into the same Chrome
// Trace Event Format file as Flutter and shorebird_cli. Prepended
// because --trace is global (must precede the subcommand).
final traceFile = buildTraceSession.traceFile;
final tracedCommand = <String>[
if (traceFile != null) '--trace=${traceFile.path}',
...command,
];
// This will be a path to either a kernel (.dill) file or a Dart script if // This will be a path to either a kernel (.dill) file or a Dart script if
// we're running with a local engine. // we're running with a local engine.
final artifactPath = shorebirdArtifacts.getArtifactPath( final artifactPath = shorebirdArtifacts.getArtifactPath(
@@ -185,7 +197,7 @@ class AotTools {
if (extension != '.dill' && extension != '.dart') { if (extension != '.dill' && extension != '.dart') {
result = await execute( result = await execute(
artifactPath, artifactPath,
command, tracedCommand,
workingDirectory: workingDirectory, workingDirectory: workingDirectory,
); );
} else { } else {
@@ -193,7 +205,7 @@ class AotTools {
result = await execute(shorebirdEnv.dartBinaryFile.path, [ result = await execute(shorebirdEnv.dartBinaryFile.path, [
'run', 'run',
artifactPath, artifactPath,
...command, ...tracedCommand,
], workingDirectory: workingDirectory); ], workingDirectory: workingDirectory);
} }
@@ -202,7 +214,7 @@ class AotTools {
exitCode: result.exitCode, exitCode: result.exitCode,
stdout: result.stdout.toString(), stdout: result.stdout.toString(),
stderr: result.stderr.toString(), stderr: result.stderr.toString(),
command: ['aot_tools', ...command].join(' '), command: ['aot_tools', ...tracedCommand].join(' '),
); );
} }
@@ -29,3 +29,56 @@ final minimumSupportedWindowsFlutterVersion = Version(3, 32, 6);
/// Obfuscation requires gen_snapshot changes (--save-obfuscation-map and /// Obfuscation requires gen_snapshot changes (--save-obfuscation-map and
/// --strip flags) that were first available in this Flutter version. /// --strip flags) that were first available in this Flutter version.
final minimumObfuscationFlutterVersion = Version(3, 41, 2); final minimumObfuscationFlutterVersion = Version(3, 41, 2);
/// A Flutter support rule that combines a minimum version floor with an
/// allowlist of specific Shorebird-fork engine revisions below the floor
/// that also satisfy the rule.
///
/// Shorebird ships its own Flutter fork, and a single upstream Flutter
/// version can back multiple Shorebird-fork engine revisions. When a
/// feature first lands in a Shorebird-fork revision of version N before
/// upstream produces N+1, a pure min-version gate of N+1 would reject
/// users on those perfectly-good N revisions. The allowlist is a bridge
/// for exactly that window: list the engine revisions of version N that
/// include the feature, and once upstream produces N+1 the allowlist
/// stops mattering.
///
/// Append a hash to [allowedRevisions] every time Shorebird re-ships the
/// pre-floor Flutter version with the feature still included.
class FlutterSupportConstraint {
/// Creates a constraint with the given [minVersion] floor and optional
/// [allowedRevisions] bridge.
const FlutterSupportConstraint({
required this.minVersion,
this.allowedRevisions = const {},
});
/// Minimum Flutter version that satisfies this constraint.
final Version minVersion;
/// Shorebird-fork engine revisions below [minVersion] that also satisfy
/// this constraint.
final Set<String> allowedRevisions;
/// Whether the given [version]/[revision] pair satisfies this constraint.
bool isSatisfiedBy({required Version version, required String revision}) =>
version >= minVersion || allowedRevisions.contains(revision);
}
/// Flutter support for `flutter build --shorebird-trace=<path>` for emitting
/// Chrome Trace Event Format build traces.
///
/// Added in shorebirdtech/flutter#116. `minVersion` is set to the next
/// minor past the latest Shorebird Flutter release (currently 3.41.6), so
/// whenever that PR gets cut as 3.41.7 the floor covers it cleanly. Until
/// then, the allowlist covers the current pin hash so users on it get
/// tracing today.
final buildTraceSupportConstraint = FlutterSupportConstraint(
minVersion: Version(3, 41, 7),
allowedRevisions: {
// Current Shorebird Flutter pin (bin/internal/flutter.version). Can
// be removed once a flutter_release/3.41.7 branch ships with this
// (or a later tracing-enabled) commit at its tip.
'3b10eecea184bb381f1045a878eeff36548ed11e',
},
);
@@ -2,13 +2,17 @@ import 'package:http/http.dart' as http;
import 'package:scoped_deps/scoped_deps.dart'; import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/http_client/logging_client.dart'; import 'package:shorebird_cli/src/http_client/logging_client.dart';
import 'package:shorebird_cli/src/http_client/retrying_client.dart'; import 'package:shorebird_cli/src/http_client/retrying_client.dart';
import 'package:shorebird_cli/src/http_client/tracing_client.dart';
export 'logging_client.dart'; export 'logging_client.dart';
export 'retrying_client.dart'; export 'retrying_client.dart';
export 'tracing_client.dart';
/// A reference to a [http.Client] instance. /// A reference to a [http.Client] instance.
final httpClientRef = create( final httpClientRef = create<http.Client>(
() => retryingHttpClient(LoggingClient(httpClient: http.Client())), () => TracingClient(
httpClient: retryingHttpClient(LoggingClient(httpClient: http.Client())),
),
); );
/// The [http.Client] instance available in the current zone. /// The [http.Client] instance available in the current zone.
@@ -0,0 +1,36 @@
import 'package:http/http.dart' as http;
import 'package:shorebird_cli/src/artifact_builder/shorebird_tracer.dart';
/// An http client that records each request as a `network`-category trace
/// event on the ambient [ShorebirdTracer]. Wraps another [http.Client];
/// intended to sit at the outermost layer so retries, logging, and any
/// other middleware roll up into the same span.
class TracingClient extends http.BaseClient {
/// Wraps [httpClient], recording a span per request.
TracingClient({required http.Client httpClient}) : _baseClient = httpClient;
final http.Client _baseClient;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final start = DateTime.now();
int? statusCode;
try {
final response = await _baseClient.send(request);
statusCode = response.statusCode;
return response;
} finally {
shorebirdTracer.addNetworkEvent(
name: '${request.method} ${request.url.host}',
start: start,
duration: DateTime.now().difference(start),
args: {
'method': request.method,
'host': request.url.host,
'status': ?statusCode,
'contentLength': ?request.contentLength,
},
);
}
}
}
@@ -29,6 +29,7 @@ class CreatePatchMetadata extends Equatable {
required this.isSigned, required this.isSigned,
this.linkPercentage, this.linkPercentage,
this.linkMetadata, this.linkMetadata,
this.buildTraceSummary,
}); });
// coverage:ignore-start // coverage:ignore-start
@@ -44,6 +45,7 @@ class CreatePatchMetadata extends Equatable {
bool isSigned = false, bool isSigned = false,
double? linkPercentage, double? linkPercentage,
Json? linkMetadata, Json? linkMetadata,
Json? buildTraceSummary,
BuildEnvironmentMetadata? environment, BuildEnvironmentMetadata? environment,
}) => CreatePatchMetadata( }) => CreatePatchMetadata(
releasePlatform: releasePlatform, releasePlatform: releasePlatform,
@@ -55,6 +57,7 @@ class CreatePatchMetadata extends Equatable {
inferredReleaseVersion: inferredReleaseVersion, inferredReleaseVersion: inferredReleaseVersion,
linkPercentage: linkPercentage, linkPercentage: linkPercentage,
linkMetadata: linkMetadata, linkMetadata: linkMetadata,
buildTraceSummary: buildTraceSummary,
environment: environment ?? BuildEnvironmentMetadata.forTest(), environment: environment ?? BuildEnvironmentMetadata.forTest(),
); );
// coverage:ignore-end // coverage:ignore-end
@@ -78,6 +81,7 @@ class CreatePatchMetadata extends Equatable {
bool? isSigned, bool? isSigned,
double? linkPercentage, double? linkPercentage,
Json? linkMetadata, Json? linkMetadata,
Json? buildTraceSummary,
BuildEnvironmentMetadata? environment, BuildEnvironmentMetadata? environment,
}) => CreatePatchMetadata( }) => CreatePatchMetadata(
releasePlatform: releasePlatform ?? this.releasePlatform, releasePlatform: releasePlatform ?? this.releasePlatform,
@@ -92,6 +96,7 @@ class CreatePatchMetadata extends Equatable {
isSigned: isSigned ?? this.isSigned, isSigned: isSigned ?? this.isSigned,
linkPercentage: linkPercentage ?? this.linkPercentage, linkPercentage: linkPercentage ?? this.linkPercentage,
linkMetadata: linkMetadata ?? this.linkMetadata, linkMetadata: linkMetadata ?? this.linkMetadata,
buildTraceSummary: buildTraceSummary ?? this.buildTraceSummary,
environment: environment ?? this.environment, environment: environment ?? this.environment,
); );
@@ -149,6 +154,15 @@ class CreatePatchMetadata extends Equatable {
/// Reason: see [BuildEnvironmentMetadata]. /// Reason: see [BuildEnvironmentMetadata].
final BuildEnvironmentMetadata environment; final BuildEnvironmentMetadata environment;
/// Privacy-safe aggregate timings from the Flutter build, produced by
/// `BuildTraceSummary.toJson()`. Shape: integer millisecond counters +
/// small categorical fields; see `BuildTraceSummary` for the schema.
/// Null when no trace was captured (older Flutter pin, user opted out,
/// trace file malformed). Stored as [Json] here to avoid this class
/// having a compile-time dep on `BuildTraceSummary`'s type — the
/// server consumes the blob as-is.
final Json? buildTraceSummary;
@override @override
List<Object?> get props => [ List<Object?> get props => [
releasePlatform, releasePlatform,
@@ -161,5 +175,6 @@ class CreatePatchMetadata extends Equatable {
inferredReleaseVersion, inferredReleaseVersion,
isSigned, isSigned,
environment, environment,
buildTraceSummary,
]; ];
} }
@@ -46,6 +46,10 @@ CreatePatchMetadata _$CreatePatchMetadataFromJson(
'link_metadata', 'link_metadata',
(v) => v as Map<String, dynamic>?, (v) => v as Map<String, dynamic>?,
), ),
buildTraceSummary: $checkedConvert(
'build_trace_summary',
(v) => v as Map<String, dynamic>?,
),
); );
return val; return val;
}, },
@@ -59,6 +63,7 @@ CreatePatchMetadata _$CreatePatchMetadataFromJson(
'isSigned': 'is_signed', 'isSigned': 'is_signed',
'linkPercentage': 'link_percentage', 'linkPercentage': 'link_percentage',
'linkMetadata': 'link_metadata', 'linkMetadata': 'link_metadata',
'buildTraceSummary': 'build_trace_summary',
}, },
); );
@@ -75,6 +80,7 @@ Map<String, dynamic> _$CreatePatchMetadataToJson(
'link_metadata': instance.linkMetadata, 'link_metadata': instance.linkMetadata,
'is_signed': instance.isSigned, 'is_signed': instance.isSigned,
'environment': instance.environment.toJson(), 'environment': instance.environment.toJson(),
'build_trace_summary': instance.buildTraceSummary,
}; };
const _$ReleasePlatformEnumMap = { const _$ReleasePlatformEnumMap = {
@@ -24,6 +24,7 @@ class UpdateReleaseMetadata extends Equatable {
required this.environment, required this.environment,
required this.includesPublicKey, required this.includesPublicKey,
this.generatedApks, this.generatedApks,
this.buildTraceSummary,
}); });
// coverage:ignore-start // coverage:ignore-start
@@ -35,12 +36,14 @@ class UpdateReleaseMetadata extends Equatable {
bool? generatedApks = false, bool? generatedApks = false,
bool includesPublicKey = false, bool includesPublicKey = false,
BuildEnvironmentMetadata? environment, BuildEnvironmentMetadata? environment,
Json? buildTraceSummary,
}) => UpdateReleaseMetadata( }) => UpdateReleaseMetadata(
releasePlatform: releasePlatform, releasePlatform: releasePlatform,
flutterVersionOverride: flutterVersionOverride, flutterVersionOverride: flutterVersionOverride,
generatedApks: generatedApks, generatedApks: generatedApks,
environment: environment ?? BuildEnvironmentMetadata.forTest(), environment: environment ?? BuildEnvironmentMetadata.forTest(),
includesPublicKey: includesPublicKey, includesPublicKey: includesPublicKey,
buildTraceSummary: buildTraceSummary,
); );
// coverage:ignore-end // coverage:ignore-end
@@ -59,6 +62,7 @@ class UpdateReleaseMetadata extends Equatable {
bool? generatedApks, bool? generatedApks,
BuildEnvironmentMetadata? environment, BuildEnvironmentMetadata? environment,
bool? includesPublicKey, bool? includesPublicKey,
Json? buildTraceSummary,
}) => UpdateReleaseMetadata( }) => UpdateReleaseMetadata(
releasePlatform: releasePlatform ?? this.releasePlatform, releasePlatform: releasePlatform ?? this.releasePlatform,
flutterVersionOverride: flutterVersionOverride:
@@ -66,6 +70,7 @@ class UpdateReleaseMetadata extends Equatable {
generatedApks: generatedApks ?? this.generatedApks, generatedApks: generatedApks ?? this.generatedApks,
environment: environment ?? this.environment, environment: environment ?? this.environment,
includesPublicKey: includesPublicKey ?? this.includesPublicKey, includesPublicKey: includesPublicKey ?? this.includesPublicKey,
buildTraceSummary: buildTraceSummary ?? this.buildTraceSummary,
); );
/// The platform for which the patch was created. /// The platform for which the patch was created.
@@ -97,6 +102,15 @@ class UpdateReleaseMetadata extends Equatable {
/// Reason: see [BuildEnvironmentMetadata]. /// Reason: see [BuildEnvironmentMetadata].
final BuildEnvironmentMetadata environment; final BuildEnvironmentMetadata environment;
/// Privacy-safe aggregate timings from the Flutter build, produced by
/// `BuildTraceSummary.toJson()`. Shape: integer millisecond counters +
/// small categorical fields; see `BuildTraceSummary` for the schema.
/// Null when no trace was captured (older Flutter pin, user opted out,
/// trace file malformed). Stored as [Json] here to avoid this class
/// having a compile-time dep on `BuildTraceSummary`'s type — the
/// server consumes the blob as-is.
final Json? buildTraceSummary;
@override @override
List<Object?> get props => [ List<Object?> get props => [
releasePlatform, releasePlatform,
@@ -104,5 +118,6 @@ class UpdateReleaseMetadata extends Equatable {
generatedApks, generatedApks,
includesPublicKey, includesPublicKey,
environment, environment,
buildTraceSummary,
]; ];
} }
@@ -32,6 +32,10 @@ UpdateReleaseMetadata _$UpdateReleaseMetadataFromJson(
(v) => v as bool?, (v) => v as bool?,
), ),
generatedApks: $checkedConvert('generated_apks', (v) => v as bool?), generatedApks: $checkedConvert('generated_apks', (v) => v as bool?),
buildTraceSummary: $checkedConvert(
'build_trace_summary',
(v) => v as Map<String, dynamic>?,
),
); );
return val; return val;
}, },
@@ -40,6 +44,7 @@ UpdateReleaseMetadata _$UpdateReleaseMetadataFromJson(
'flutterVersionOverride': 'flutter_version_override', 'flutterVersionOverride': 'flutter_version_override',
'includesPublicKey': 'includes_public_key', 'includesPublicKey': 'includes_public_key',
'generatedApks': 'generated_apks', 'generatedApks': 'generated_apks',
'buildTraceSummary': 'build_trace_summary',
}, },
); );
@@ -51,6 +56,7 @@ Map<String, dynamic> _$UpdateReleaseMetadataToJson(
'generated_apks': instance.generatedApks, 'generated_apks': instance.generatedApks,
'includes_public_key': instance.includesPublicKey, 'includes_public_key': instance.includesPublicKey,
'environment': instance.environment.toJson(), 'environment': instance.environment.toJson(),
'build_trace_summary': instance.buildTraceSummary,
}; };
const _$ReleasePlatformEnumMap = { const _$ReleasePlatformEnumMap = {
@@ -36,6 +36,7 @@ class ShorebirdProcess {
Map<String, String>? environment, Map<String, String>? environment,
bool? runInShell, bool? runInShell,
String? workingDirectory, String? workingDirectory,
void Function(Process process)? onStart,
}) async { }) async {
final process = await start( final process = await start(
executable, executable,
@@ -45,6 +46,7 @@ class ShorebirdProcess {
workingDirectory: workingDirectory, workingDirectory: workingDirectory,
mode: ProcessStartMode.inheritStdio, mode: ProcessStartMode.inheritStdio,
); );
onStart?.call(process);
return process.exitCode; return process.exitCode;
} }
+2
View File
@@ -40,6 +40,8 @@ dependencies:
pubspec_parse: ^1.5.0 pubspec_parse: ^1.5.0
retry: ^3.1.2 retry: ^3.1.2
scoped_deps: ^0.1.0 scoped_deps: ^0.1.0
shorebird_build_trace:
path: ../shorebird_build_trace
shorebird_code_push_client: shorebird_code_push_client:
path: ../shorebird_code_push_client path: ../shorebird_code_push_client
shorebird_code_push_protocol: shorebird_code_push_protocol:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,199 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/artifact_builder/build_environment.dart';
import 'package:test/test.dart';
void main() {
group(BuildEnvironment, () {
late Directory tmp;
setUp(() => tmp = Directory.systemTemp.createTempSync());
tearDown(() => tmp.deleteSync(recursive: true));
Directory makeProjectRoot({String? gradleProperties}) {
final root = Directory(p.join(tmp.path, 'app'))..createSync();
Directory(p.join(root.path, 'android')).createSync();
if (gradleProperties != null) {
File(
p.join(root.path, 'android', 'gradle.properties'),
).writeAsStringSync(gradleProperties);
}
return root;
}
Directory makeHome({String? gradleProperties, String? initScript}) {
final home = Directory(p.join(tmp.path, 'home'))..createSync();
final gradleDir = Directory(p.join(home.path, '.gradle'))..createSync();
if (gradleProperties != null) {
File(
p.join(gradleDir.path, 'gradle.properties'),
).writeAsStringSync(gradleProperties);
}
if (initScript != null) {
Directory(p.join(gradleDir.path, 'init.d')).createSync();
File(
p.join(gradleDir.path, 'init.d', 'develocity.gradle'),
).writeAsStringSync(initScript);
}
return home;
}
test('default empty env → all caching disabled, no CI', () {
final env = BuildEnvironment.detect(
environment: const <String, String>{},
homeDir: tmp,
projectRoot: tmp,
);
expect(env.isCi, isFalse);
expect(env.ciProvider, isNull);
expect(env.gradleBuildCacheEnabled, isFalse);
expect(env.gradleConfigurationCacheEnabled, isFalse);
expect(env.gradleParallelEnabled, isFalse);
expect(env.gradleDaemonEnabled, isTrue); // default-on
expect(env.gradleDevelocityDetected, isFalse);
expect(env.gradleInitScriptCount, 0);
expect(env.iosCcacheAvailable, isFalse);
});
test('reads project gradle.properties for cache + parallel', () {
final root = makeProjectRoot(
gradleProperties: '''
# Comment line, ignored
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.daemon=false
org.gradle.configuration-cache=true
''',
);
final env = BuildEnvironment.detect(
environment: const <String, String>{},
homeDir: tmp,
projectRoot: root,
);
expect(env.gradleBuildCacheEnabled, isTrue);
expect(env.gradleParallelEnabled, isTrue);
expect(env.gradleDaemonEnabled, isFalse);
expect(env.gradleConfigurationCacheEnabled, isTrue);
});
test('detects Develocity init script', () {
final home = makeHome(
initScript: 'apply(plugin: "com.gradle.develocity")',
);
final env = BuildEnvironment.detect(
environment: const <String, String>{},
homeDir: home,
projectRoot: tmp,
);
expect(env.gradleDevelocityDetected, isTrue);
expect(env.gradleInitScriptCount, 1);
});
test('detects legacy com.gradle.enterprise marker', () {
final home = makeHome(
initScript: 'apply(plugin: "com.gradle.enterprise")',
);
final env = BuildEnvironment.detect(
environment: const <String, String>{},
homeDir: home,
projectRoot: tmp,
);
expect(env.gradleDevelocityDetected, isTrue);
});
test('detects develocity { ... } block in project settings.gradle.kts', () {
final root = Directory(p.join(tmp.path, 'proj'))..createSync();
Directory(p.join(root.path, 'android')).createSync();
File(
p.join(root.path, 'android', 'settings.gradle.kts'),
).writeAsStringSync('develocity {\n server = "..."\n}\n');
final env = BuildEnvironment.detect(
environment: const <String, String>{},
homeDir: tmp,
projectRoot: root,
);
expect(env.gradleDevelocityDetected, isTrue);
});
test('detects gradleEnterprise { ... } block in settings.gradle', () {
final root = Directory(p.join(tmp.path, 'proj2'))..createSync();
Directory(p.join(root.path, 'android')).createSync();
File(
p.join(root.path, 'android', 'settings.gradle'),
).writeAsStringSync('gradleEnterprise {\n}\n');
final env = BuildEnvironment.detect(
environment: const <String, String>{},
homeDir: tmp,
projectRoot: root,
);
expect(env.gradleDevelocityDetected, isTrue);
});
test('recognizes .gradle.kts init scripts under ~/.gradle/init.d', () {
final home = Directory(p.join(tmp.path, 'home2'))..createSync();
final initDir = Directory(p.join(home.path, '.gradle', 'init.d'))
..createSync(recursive: true);
File(
p.join(initDir.path, 'develocity.gradle.kts'),
).writeAsStringSync('develocity {\n}\n');
final env = BuildEnvironment.detect(
environment: const <String, String>{},
homeDir: home,
projectRoot: tmp,
);
expect(env.gradleDevelocityDetected, isTrue);
expect(env.gradleInitScriptCount, 1);
});
test('classifies common CI providers', () {
expect(
BuildEnvironment.detect(
environment: const <String, String>{'GITHUB_ACTIONS': 'true'},
).ciProvider,
'github',
);
expect(
BuildEnvironment.detect(
environment: const <String, String>{'CI': 'true'},
).ciProvider,
'other',
);
expect(
BuildEnvironment.detect(
environment: const <String, String>{'CIRCLECI': 'true'},
).ciProvider,
'circle',
);
});
test('toJson is privacy-safe (only bool/int/enum-string)', () {
final env = BuildEnvironment.detect(
environment: const <String, String>{'GITHUB_ACTIONS': 'true'},
);
final j = env.toJson();
void checkLeaf(Object? v) {
expect(
v,
anyOf(isA<bool>(), isA<int>(), isA<String>(), isNull),
reason: 'leaf $v not a privacy-safe scalar',
);
// String leaves are limited to small enums.
if (v is String) {
expect(v.length, lessThan(40));
expect(v.contains('/'), isFalse);
expect(v.contains(r'\'), isFalse);
}
}
void walk(Object? node) {
if (node is Map) {
node.values.forEach(walk);
} else {
checkLeaf(node);
}
}
walk(j);
});
});
}
@@ -0,0 +1,51 @@
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/artifact_builder/build_trace_session.dart';
import 'package:test/test.dart';
void main() {
group(BuildTraceSession, () {
test('holds commandStartedAt', () {
final started = DateTime.utc(2026, 4, 17, 12, 30);
final session = BuildTraceSession(commandStartedAt: started);
expect(session.commandStartedAt, started);
});
});
group('buildTraceSessionRef', () {
test('default factory produces a session with a recent start time', () {
final before = DateTime.now();
final session = runScoped(
() => buildTraceSession,
values: {buildTraceSessionRef},
);
final after = DateTime.now();
expect(session, isA<BuildTraceSession>());
expect(
session.commandStartedAt.isAfter(
before.subtract(const Duration(seconds: 1)),
),
isTrue,
);
expect(
session.commandStartedAt.isBefore(
after.add(const Duration(seconds: 1)),
),
isTrue,
);
});
test('overrideWith replaces the default session', () {
final fixed = DateTime.utc(2020);
final session = runScoped(
() => buildTraceSession,
values: {
buildTraceSessionRef.overrideWith(
() => BuildTraceSession(commandStartedAt: fixed),
),
},
);
expect(session.commandStartedAt, fixed);
});
});
}
@@ -0,0 +1,678 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/artifact_builder/build_trace_summary.dart';
import 'package:shorebird_cli/src/artifact_builder/duration_distribution.dart';
import 'package:test/test.dart';
Map<String, Object?> _event({
required String name,
required String cat,
required int ts,
required int dur,
required int tid,
Map<String, Object?>? args,
}) => {
'ph': 'X',
'name': name,
'cat': cat,
'ts': ts,
'dur': dur,
'pid': 1,
'tid': tid,
'args': ?args,
};
void main() {
group(BuildTraceSummary, () {
test('empty events → zero summary', () {
final s = BuildTraceSummary.fromEvents([], platform: 'android');
expect(s.flutterBuild, Duration.zero);
expect(s.dart.total, Duration.zero);
expect(s.flutterAssemble.targetCount, 0);
expect(s.shorebirdOverhead, isNull);
// Platform is android → android populated, ios null.
expect(s.android, isNotNull);
expect(s.ios, isNull);
});
test('unknown cat is dropped — does not contribute to any bucket', () {
// A future producer emits a category this consumer doesn't know yet.
// Forward-compat contract: parse to TraceCategory.unknown and skip.
final events = [
_event(
name: 'mystery-event',
cat: 'brand-new-future-category',
ts: 0,
dur: 9_999_999,
tid: 1,
),
];
final s = BuildTraceSummary.fromEvents(events, platform: 'android');
expect(s.flutterBuild, Duration.zero);
expect(s.native.build, Duration.zero);
expect(s.network.duration, Duration.zero);
expect(s.flutterAssemble.targetCount, 0);
});
test('android trace → nested gradle + android stats', () {
final events = [
_event(
name: 'pre-gradle setup',
cat: 'flutter',
ts: 0,
dur: 2000,
tid: 1,
),
_event(
name: 'gradle assembleRelease',
cat: 'gradle',
ts: 2000,
dur: 3_000_000,
tid: 2,
),
_event(
name: 'kernel_snapshot_program',
cat: 'assemble',
ts: 3000,
dur: 500_000,
tid: 3,
),
_event(
name: 'android_aot',
cat: 'assemble',
ts: 503_000,
dur: 200_000,
tid: 3,
),
_event(
name: 'dart_build',
cat: 'assemble',
ts: 703_000,
dur: 100_000,
tid: 3,
),
// per-task events (tid=4, cat=gradle_task)
for (final dur in const [1_000_000, 2_000_000, 5_000_000])
_event(
name: ':some_plugin:compileReleaseKotlin',
cat: 'gradle_task',
ts: 0,
dur: dur,
tid: 4,
args: {'kind': 'kotlin_compile'},
),
_event(
name: ':app:minifyReleaseWithR8',
cat: 'gradle_task',
ts: 0,
dur: 20_000_000,
tid: 4,
args: {'kind': 'r8_minify'},
),
_event(
name: 'POST api.shorebird.dev',
cat: 'network',
ts: 0,
dur: 300_000,
tid: 5,
),
_event(
name: 'flutter build appbundle',
cat: 'flutter',
ts: 0,
dur: 3_005_000,
tid: 1,
),
];
final s = BuildTraceSummary.fromEvents(
events,
platform: 'android',
shorebirdOverhead: const Duration(milliseconds: 500),
);
// Top level
expect(s.flutterBuild, const Duration(milliseconds: 3005));
expect(s.shorebirdOverhead, const Duration(milliseconds: 500));
expect(s.total, const Duration(milliseconds: 3505));
// shorebirdLocal = overhead 500 network 300 = 200
expect(s.shorebirdLocal, const Duration(milliseconds: 200));
// Network
expect(s.network.duration, const Duration(milliseconds: 300));
expect(s.network.callCount, 1);
// Dart
expect(s.dart.total, const Duration(milliseconds: 700));
expect(s.dart.kernelSnapshot, const Duration(milliseconds: 500));
expect(s.dart.genSnapshot, const Duration(milliseconds: 200));
expect(s.dart.build, const Duration(milliseconds: 100));
// Native (outer 3000ms sum of assemble 800ms = 2200ms)
expect(s.native.build, const Duration(milliseconds: 3000));
expect(s.native.compile, const Duration(milliseconds: 2200));
// Flutter tool
expect(s.flutterTool, const Duration(milliseconds: 2));
// Android-specific
expect(s.android, isNotNull);
final g = s.android!.gradle;
expect(g.taskDistribution.count, 4);
// Kotlin sum: 1+2+5 = 8s
expect(g.kotlinCompile, const Duration(milliseconds: 8000));
expect(g.r8Minify, const Duration(milliseconds: 20000));
expect(g.taskDistribution.max, const Duration(milliseconds: 20000));
// Sorted us: [1M, 2M, 5M, 20M]; floor(4*0.5)=2 → 5M; floor(4*0.9)=3 → 20M
expect(g.taskDistribution.p50, const Duration(milliseconds: 5000));
expect(g.taskDistribution.p90, const Duration(milliseconds: 20000));
expect(s.ios, isNull);
});
test('ios trace → nested podInstall + xcode stats', () {
final events = [
_event(
name: 'pod install',
cat: 'subprocess',
ts: 0,
dur: 60_000_000,
tid: 1,
),
_event(
name: 'pod install: analyzing',
cat: 'subprocess',
ts: 0,
dur: 5_000_000,
tid: 1,
),
_event(
name: 'pod install: downloading',
cat: 'subprocess',
ts: 5_000_000,
dur: 30_000_000,
tid: 1,
),
_event(
name: 'pod install: generating',
cat: 'subprocess',
ts: 35_000_000,
dur: 20_000_000,
tid: 1,
),
_event(
name: 'pod install: integrating',
cat: 'subprocess',
ts: 55_000_000,
dur: 5_000_000,
tid: 1,
),
_event(
name: 'xcode archive',
cat: 'xcode',
ts: 0,
dur: 100_000_000,
tid: 2,
),
// xcode subsections on tid=4 cat=xcode_subsection
_event(
name: 'Build target A',
cat: 'xcode_subsection',
ts: 0,
dur: 30_000_000,
tid: 4,
),
_event(
name: 'Build target B',
cat: 'xcode_subsection',
ts: 0,
dur: 25_000_000,
tid: 4,
),
_event(
name: 'Build target C',
cat: 'xcode_subsection',
ts: 0,
dur: 5_000_000,
tid: 4,
),
_event(
name: 'Build target D',
cat: 'xcode_subsection',
ts: 0,
dur: 2_000_000,
tid: 4,
),
_event(
name: 'Build target E',
cat: 'xcode_subsection',
ts: 0,
dur: 1_000_000,
tid: 4,
),
_event(
name: 'flutter build ios',
cat: 'flutter',
ts: 0,
dur: 200_000_000,
tid: 1,
),
];
final s = BuildTraceSummary.fromEvents(events, platform: 'ios');
expect(s.ios, isNotNull);
expect(s.android, isNull);
expect(s.ios!.podInstall.duration, const Duration(milliseconds: 60000));
expect(s.ios!.podInstall.analyze, const Duration(milliseconds: 5000));
expect(s.ios!.podInstall.download, const Duration(milliseconds: 30000));
expect(s.ios!.podInstall.generate, const Duration(milliseconds: 20000));
expect(s.ios!.podInstall.integrate, const Duration(milliseconds: 5000));
expect(s.ios!.xcode.subsectionDistribution.count, 5);
// Sum: 30+25+5+2+1 = 63s
expect(
s.ios!.xcode.subsectionDistribution.sum,
const Duration(milliseconds: 63000),
);
expect(
s.ios!.xcode.subsectionDistribution.max,
const Duration(milliseconds: 30000),
);
// Sorted us: [1M, 2M, 5M, 25M, 30M]
// floor(5*0.5)=2 → 5M; floor(5*0.9)=4 → 30M
expect(
s.ios!.xcode.subsectionDistribution.p50,
const Duration(milliseconds: 5000),
);
expect(
s.ios!.xcode.subsectionDistribution.p90,
const Duration(milliseconds: 30000),
);
});
test('toJson shape is nested and omits the other platform', () {
final events = [
_event(
name: 'flutter build appbundle',
cat: 'flutter',
ts: 0,
dur: 10_000_000,
tid: 1,
),
];
final s = BuildTraceSummary.fromEvents(events, platform: 'android');
final j = s.toJson();
expect(j['version'], 8);
expect(j['platform'], 'android');
expect(j['android'], isA<Map<String, Object?>>());
expect(j.containsKey('ios'), isFalse);
final android = j['android']! as Map<String, Object?>;
expect(android['gradle'], isA<Map<String, Object?>>());
// No path/name/user identifiers at any level.
final flat = jsonEncode(j).toLowerCase();
expect(flat.contains('"path"'), isFalse);
expect(flat.contains('"file"'), isFalse);
expect(flat.contains('"user"'), isFalse);
});
group('tryFromFile', () {
late Directory tempDir;
setUp(() => tempDir = Directory.systemTemp.createTempSync());
tearDown(() => tempDir.deleteSync(recursive: true));
test('returns null if file is missing', () {
final s = BuildTraceSummary.tryFromFile(
File(p.join(tempDir.path, 'missing.json')),
platform: 'android',
);
expect(s, isNull);
});
test('returns null for malformed JSON', () {
final f = File(p.join(tempDir.path, 'bad.json'))
..writeAsStringSync('not json');
expect(
BuildTraceSummary.tryFromFile(f, platform: 'android'),
isNull,
);
});
test('parses a trace array', () {
final f = File(p.join(tempDir.path, 'trace.json'))
..writeAsStringSync(
jsonEncode([
_event(
name: 'kernel_snapshot_program',
cat: 'assemble',
ts: 0,
dur: 1_000_000,
tid: 3,
),
_event(
name: 'flutter build apk',
cat: 'flutter',
ts: 0,
dur: 1_500_000,
tid: 1,
),
]),
);
final s = BuildTraceSummary.tryFromFile(f, platform: 'android');
expect(s, isNotNull);
expect(s!.flutterBuild, const Duration(milliseconds: 1500));
expect(s.dart.kernelSnapshot, const Duration(milliseconds: 1000));
expect(s.dart.total, const Duration(milliseconds: 1000));
});
test('parses a {"traceEvents": [...]} object shape', () {
final f = File(p.join(tempDir.path, 'trace.json'))
..writeAsStringSync(
jsonEncode({
'traceEvents': [
_event(
name: 'flutter build apk',
cat: 'flutter',
ts: 0,
dur: 500_000,
tid: 1,
),
],
}),
);
final s = BuildTraceSummary.tryFromFile(f, platform: 'android');
expect(s, isNotNull);
expect(s!.flutterBuild, const Duration(milliseconds: 500));
});
test('returns null when the JSON root is not a list or known object', () {
final f = File(p.join(tempDir.path, 'weird.json'))
..writeAsStringSync(jsonEncode({'unexpected': true}));
expect(
BuildTraceSummary.tryFromFile(f, platform: 'android'),
isNull,
);
});
});
group('assemble category classification', () {
test('aot_assembly / aot_elf / ios_aot → genSnapshot bucket', () {
final s = BuildTraceSummary.fromEvents([
_event(
name: 'aot_assembly_release',
cat: 'assemble',
ts: 0,
dur: 1_000_000,
tid: 3,
),
_event(
name: 'aot_elf_release',
cat: 'assemble',
ts: 0,
dur: 2_000_000,
tid: 3,
),
_event(
name: 'ios_aot',
cat: 'assemble',
ts: 0,
dur: 3_000_000,
tid: 3,
),
], platform: 'android');
expect(s.dart.genSnapshot, const Duration(milliseconds: 6000));
});
test('dart_build → DartStats.build', () {
final s = BuildTraceSummary.fromEvents([
_event(
name: 'dart_build',
cat: 'assemble',
ts: 0,
dur: 500_000,
tid: 3,
),
], platform: 'android');
expect(s.dart.build, const Duration(milliseconds: 500));
});
test('gen_* → FlutterAssembleStats.codegen', () {
final s = BuildTraceSummary.fromEvents([
_event(
name: 'gen_localizations',
cat: 'assemble',
ts: 0,
dur: 600_000,
tid: 3,
),
], platform: 'android');
expect(s.flutterAssemble.codegen, const Duration(milliseconds: 600));
});
test('various asset-like names → assets bucket', () {
final s = BuildTraceSummary.fromEvents([
_event(
name: 'bundle_flutter_assets_release',
cat: 'assemble',
ts: 0,
dur: 100_000,
tid: 3,
),
_event(
name: 'install_code_assets',
cat: 'assemble',
ts: 0,
dur: 200_000,
tid: 3,
),
_event(
name: 'unpack_macos',
cat: 'assemble',
ts: 0,
dur: 300_000,
tid: 3,
),
_event(
name: 'copy_framework',
cat: 'assemble',
ts: 0,
dur: 400_000,
tid: 3,
),
], platform: 'android');
expect(s.flutterAssemble.assets, const Duration(milliseconds: 1000));
});
test('unknown name → other bucket', () {
final s = BuildTraceSummary.fromEvents([
_event(
name: 'some_random_target',
cat: 'assemble',
ts: 0,
dur: 700_000,
tid: 3,
),
], platform: 'android');
expect(s.flutterAssemble.other, const Duration(milliseconds: 700));
});
test('skipped:true bumps the skippedCount', () {
final s = BuildTraceSummary.fromEvents([
_event(
name: 'copy_framework',
cat: 'assemble',
ts: 0,
dur: 1000,
tid: 3,
args: {'skipped': true},
),
], platform: 'android');
expect(s.flutterAssemble.skippedCount, 1);
expect(s.flutterAssemble.targetCount, 1);
});
});
group('gradle task kinds', () {
Map<String, Object?> _gradle(String kind, int durMs) => _event(
name: kind,
cat: 'gradle_task',
ts: 0,
dur: durMs * 1000,
tid: 4,
args: {'kind': kind},
);
test('all kinds populate their respective buckets', () {
final s = BuildTraceSummary.fromEvents([
_gradle('kotlin_compile', 10),
_gradle('java_compile', 20),
_gradle('dex', 30),
_gradle('resources', 40),
_gradle('transform', 50),
_gradle('r8_minify', 60),
_gradle('lint', 70),
_gradle('flutter_gradle_plugin', 80),
_gradle('bundle', 90),
_gradle('packaging', 100),
_gradle('aidl', 110),
_gradle('native_link', 120),
_gradle('gradle_scaffold', 130),
], platform: 'android');
final g = s.android!.gradle;
expect(g.kotlinCompile, const Duration(milliseconds: 10));
expect(g.javaCompile, const Duration(milliseconds: 20));
expect(g.dex, const Duration(milliseconds: 30));
expect(g.resources, const Duration(milliseconds: 40));
expect(g.transform, const Duration(milliseconds: 50));
expect(g.r8Minify, const Duration(milliseconds: 60));
expect(g.lint, const Duration(milliseconds: 70));
expect(g.flutterGradlePlugin, const Duration(milliseconds: 80));
expect(g.bundle, const Duration(milliseconds: 90));
expect(g.packaging, const Duration(milliseconds: 100));
expect(g.aidl, const Duration(milliseconds: 110));
expect(g.nativeLink, const Duration(milliseconds: 120));
expect(g.gradleScaffold, const Duration(milliseconds: 130));
});
test('cache / up-to-date / executed task counters increment', () {
final s = BuildTraceSummary.fromEvents([
_event(
name: 'a',
cat: 'gradle_task',
ts: 0,
dur: 1000,
tid: 4,
args: {'kind': 'kotlin_compile', 'fromCache': true},
),
_event(
name: 'b',
cat: 'gradle_task',
ts: 0,
dur: 1000,
tid: 4,
args: {'kind': 'kotlin_compile', 'upToDate': true},
),
_event(
name: 'c',
cat: 'gradle_task',
ts: 0,
dur: 1000,
tid: 4,
args: {'kind': 'kotlin_compile'},
),
], platform: 'android');
final g = s.android!.gradle;
expect(g.taskFromCacheCount, 1);
expect(g.taskUpToDateCount, 1);
expect(g.taskExecutedCount, 1);
});
});
group('iOS stats', () {
test('xcode_subsection events populate XcodeStats histogram', () {
final s = BuildTraceSummary.fromEvents([
for (final dur in const [100, 200, 300, 1000])
_event(
name: 'Build target Foo',
cat: 'xcode_subsection',
ts: 0,
dur: dur * 1000,
tid: 4,
),
], platform: 'ios');
final xcode = s.ios!.xcode;
expect(xcode.subsectionDistribution.count, 4);
expect(
xcode.subsectionDistribution.sum,
const Duration(milliseconds: 1600),
);
expect(
xcode.subsectionDistribution.max,
const Duration(milliseconds: 1000),
);
expect(
xcode.subsectionDistribution.p50,
greaterThanOrEqualTo(const Duration(milliseconds: 100)),
);
});
test('XcodeStats.toJson serializes all fields', () {
final xcode = XcodeStats(
subsectionDistribution: DurationDistribution(
count: 1,
sum: const Duration(milliseconds: 2),
p50: const Duration(milliseconds: 3),
p90: const Duration(milliseconds: 4),
max: const Duration(milliseconds: 5),
),
);
expect(xcode.toJson(), {
'subsectionDistribution': {
'count': 1,
'sumMs': 2,
'p50Ms': 3,
'p90Ms': 4,
'maxMs': 5,
},
});
});
test('PodInstallStats.toJson serializes all fields', () {
final stats = PodInstallStats(
duration: const Duration(milliseconds: 1),
analyze: const Duration(milliseconds: 2),
download: const Duration(milliseconds: 3),
generate: const Duration(milliseconds: 4),
integrate: const Duration(milliseconds: 5),
);
expect(stats.toJson(), {
'ms': 1,
'analyzeMs': 2,
'downloadMs': 3,
'generateMs': 4,
'integrateMs': 5,
});
});
test('IosStats.toJson nests pod + xcode', () {
final iosStats = IosStats(
podInstall: PodInstallStats(
duration: Duration.zero,
analyze: Duration.zero,
download: Duration.zero,
generate: Duration.zero,
integrate: Duration.zero,
),
xcode: XcodeStats(
subsectionDistribution: DurationDistribution.empty(),
),
);
final json = iosStats.toJson();
expect(json.keys, containsAll(<String>['podInstall', 'xcode']));
});
});
});
}
@@ -0,0 +1,172 @@
import 'dart:convert';
import 'dart:io';
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/artifact_builder/shorebird_tracer.dart';
import 'package:test/test.dart';
void main() {
group(ShorebirdTracer, () {
late ShorebirdTracer tracer;
setUp(() {
tracer = ShorebirdTracer();
});
test('addNetworkEvent writes a cat=network span on network tid', () {
tracer.addNetworkEvent(
name: 'GET api.shorebird.dev',
start: DateTime.fromMicrosecondsSinceEpoch(0),
duration: const Duration(microseconds: 1),
);
expect(tracer.events, hasLength(1));
final e = tracer.events.single;
expect(e['cat'], 'network');
expect(e['tid'], 1);
expect(e['pid'], isA<int>());
});
test('span records a completed event for a successful body', () async {
final result = await tracer.span<int>(
name: 'unit-test',
category: 'shorebird',
body: () async => 42,
);
expect(result, 42);
expect(tracer.events, hasLength(1));
final e = tracer.events.single;
expect(e['name'], 'unit-test');
expect(e['cat'], 'shorebird');
});
test('span records an event even when body throws', () async {
await expectLater(
tracer.span<int>(
name: 'unit-test',
category: 'shorebird',
body: () async => throw StateError('boom'),
),
throwsA(isA<StateError>()),
);
expect(tracer.events, hasLength(1));
expect(tracer.events.single['name'], 'unit-test');
});
test('span forwards args', () async {
await tracer.span<void>(
name: 'x',
category: 'c',
args: {'k': 'v'},
body: () async {},
);
expect(tracer.events.single['args'], {'k': 'v'});
});
test('addSpawnFlowStart emits ph:s flow event', () {
tracer.addSpawnFlowStart(
id: 4242,
at: DateTime.fromMicrosecondsSinceEpoch(1000),
);
final event = tracer.events.single;
expect(event['ph'], 's');
expect(event['id'], 4242);
expect(event['ts'], 1000);
expect(event['bp'], 'e');
});
group('mergeInto', () {
late Directory tempDir;
late File traceFile;
setUp(() {
tempDir = Directory.systemTemp.createTempSync('shorebird_tracer_test_');
traceFile = File('${tempDir.path}/trace.json');
});
tearDown(() {
if (tempDir.existsSync()) {
tempDir.deleteSync(recursive: true);
}
});
test(
'appends events + process/thread metadata to an existing trace',
() {
traceFile.writeAsStringSync(
jsonEncode([
{
'ph': 'X',
'name': 'flutter build',
'cat': 'flutter',
'ts': 0,
'dur': 100,
'pid': 1,
'tid': 1,
},
]),
);
tracer.addNetworkEvent(
name: 'POST api.shorebird.dev',
start: DateTime.fromMicrosecondsSinceEpoch(200),
duration: const Duration(microseconds: 50),
);
tracer.mergeInto(traceFile);
final decoded = jsonDecode(traceFile.readAsStringSync()) as List;
// 1 pre-existing flutter span + 1 shorebird network span +
// 3 metadata (process_name + 2 thread_name) = 5 events.
expect(decoded, hasLength(5));
expect((decoded[0] as Map)['name'], 'flutter build');
expect((decoded[1] as Map)['name'], 'POST api.shorebird.dev');
// Metadata events come after the spans when written.
expect((decoded[2] as Map)['name'], 'process_name');
expect((decoded[3] as Map)['name'], 'thread_name');
expect((decoded[4] as Map)['name'], 'thread_name');
},
);
test('no-op when the trace file does not exist', () {
tracer.addNetworkEvent(
name: 'x',
start: DateTime.fromMicrosecondsSinceEpoch(0),
duration: const Duration(microseconds: 1),
);
tracer.mergeInto(traceFile);
expect(traceFile.existsSync(), isFalse);
});
test('no-op when existing file is not a JSON array', () {
traceFile.writeAsStringSync('{"not":"an array"}');
tracer.addNetworkEvent(
name: 'x',
start: DateTime.fromMicrosecondsSinceEpoch(0),
duration: const Duration(microseconds: 1),
);
tracer.mergeInto(traceFile);
expect(traceFile.readAsStringSync(), '{"not":"an array"}');
});
test('no-op when existing file is malformed JSON', () {
traceFile.writeAsStringSync('not json');
tracer.addNetworkEvent(
name: 'x',
start: DateTime.fromMicrosecondsSinceEpoch(0),
duration: const Duration(microseconds: 1),
);
tracer.mergeInto(traceFile);
expect(traceFile.readAsStringSync(), 'not json');
});
});
});
group('shorebirdTracerRef', () {
test('resolves to a ShorebirdTracer inside a scope', () {
final tracer = runScoped(
() => shorebirdTracer,
values: {shorebirdTracerRef},
);
expect(tracer, isA<ShorebirdTracer>());
});
});
}
@@ -9,6 +9,7 @@ import 'package:path/path.dart' as p;
import 'package:scoped_deps/scoped_deps.dart'; import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart';
import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_builder/build_trace_session.dart';
import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/cache.dart'; import 'package:shorebird_cli/src/cache.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
@@ -140,6 +141,9 @@ void main() {
aotToolsRef.overrideWith(() => aotTools), aotToolsRef.overrideWith(() => aotTools),
artifactBuilderRef.overrideWith(() => artifactBuilder), artifactBuilderRef.overrideWith(() => artifactBuilder),
artifactManagerRef.overrideWith(() => artifactManager), artifactManagerRef.overrideWith(() => artifactManager),
buildTraceSessionRef.overrideWith(
() => BuildTraceSession(commandStartedAt: DateTime(2023)),
),
cacheRef.overrideWith(() => cache), cacheRef.overrideWith(() => cache),
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
loggerRef.overrideWith(() => logger), loggerRef.overrideWith(() => logger),
@@ -167,6 +171,12 @@ void main() {
aotTools = MockAotTools(); aotTools = MockAotTools();
argResults = MockArgResults(); argResults = MockArgResults();
artifactBuilder = MockArtifactBuilder(); artifactBuilder = MockArtifactBuilder();
when(
() => artifactBuilder.prepareBuildTrace(
platform: any(named: 'platform'),
),
).thenAnswer((_) async {});
when(artifactBuilder.writeBuildTraceSummary).thenReturn(null);
artifactManager = MockArtifactManager(); artifactManager = MockArtifactManager();
cache = MockCache(); cache = MockCache();
codePushClientWrapper = MockCodePushClientWrapper(); codePushClientWrapper = MockCodePushClientWrapper();
@@ -6,6 +6,7 @@ import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:scoped_deps/scoped_deps.dart'; import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_builder/build_trace_session.dart';
import 'package:shorebird_cli/src/cache.dart'; import 'package:shorebird_cli/src/cache.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/release/release.dart'; import 'package:shorebird_cli/src/commands/release/release.dart';
@@ -55,6 +56,7 @@ void main() {
); );
late ArgResults argResults; late ArgResults argResults;
late ArtifactBuilder artifactBuilder;
late Cache cache; late Cache cache;
late CodePushClientWrapper codePushClientWrapper; late CodePushClientWrapper codePushClientWrapper;
late Directory shorebirdRoot; late Directory shorebirdRoot;
@@ -72,6 +74,10 @@ void main() {
return runScoped( return runScoped(
body, body,
values: { values: {
artifactBuilderRef.overrideWith(() => artifactBuilder),
buildTraceSessionRef.overrideWith(
() => BuildTraceSession(commandStartedAt: DateTime(2023)),
),
cacheRef.overrideWith(() => cache), cacheRef.overrideWith(() => cache),
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
loggerRef.overrideWith(() => logger), loggerRef.overrideWith(() => logger),
@@ -92,6 +98,13 @@ void main() {
setUp(() { setUp(() {
argResults = MockArgResults(); argResults = MockArgResults();
artifactBuilder = MockArtifactBuilder();
when(
() => artifactBuilder.prepareBuildTrace(
platform: any(named: 'platform'),
),
).thenAnswer((_) async {});
when(artifactBuilder.writeBuildTraceSummary).thenReturn(null);
cache = MockCache(); cache = MockCache();
codePushClientWrapper = MockCodePushClientWrapper(); codePushClientWrapper = MockCodePushClientWrapper();
logger = MockShorebirdLogger(); logger = MockShorebirdLogger();
@@ -5,6 +5,7 @@ import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:pub_semver/pub_semver.dart'; import 'package:pub_semver/pub_semver.dart';
import 'package:scoped_deps/scoped_deps.dart'; import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/artifact_builder/build_trace_session.dart';
import 'package:shorebird_cli/src/cache.dart'; import 'package:shorebird_cli/src/cache.dart';
import 'package:shorebird_cli/src/executables/executables.dart'; import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/logging/logging.dart';
@@ -26,10 +27,18 @@ void main() {
late File dartBinaryFile; late File dartBinaryFile;
late AotTools aotTools; late AotTools aotTools;
R runWithOverrides<R>(R Function() body) { R runWithOverrides<R>(
R Function() body, {
BuildTraceSession? traceSession,
}) {
return runScoped( return runScoped(
body, body,
values: { values: {
buildTraceSessionRef.overrideWith(
() =>
traceSession ??
BuildTraceSession(commandStartedAt: DateTime(2023)),
),
cacheRef.overrideWith(() => cache), cacheRef.overrideWith(() => cache),
processRef.overrideWith(() => process), processRef.overrideWith(() => process),
shorebirdArtifactsRef.overrideWith(() => shorebirdArtifacts), shorebirdArtifactsRef.overrideWith(() => shorebirdArtifacts),
@@ -1085,5 +1094,96 @@ Run "aot_tools help <command>" for more information about a command.
expect(result, Version(1, 2, 3)); expect(result, Version(1, 2, 3));
}); });
}); });
group('build trace wiring', () {
test(
'prepends --trace=<path> to the subcommand when session has a '
'trace file set',
() async {
final tempDir = Directory.systemTemp.createTempSync();
addTearDown(() => tempDir.deleteSync(recursive: true));
final traceFile = File(p.join(tempDir.path, 'trace.json'));
when(
() => process.start(
any(),
any(),
workingDirectory: any(named: 'workingDirectory'),
),
).thenAnswer((_) async {
final mockProcess = MockProcess();
when(() => mockProcess.exitCode).thenAnswer((_) async => 0);
when(
() => mockProcess.stdout,
).thenAnswer((_) => Stream.value(utf8.encode('1.2.3')));
when(
() => mockProcess.stderr,
).thenAnswer((_) => const Stream<List<int>>.empty());
return mockProcess;
});
final session = BuildTraceSession(
commandStartedAt: DateTime(2023),
)..traceFile = traceFile;
await runWithOverrides(
() => aotTools.getVersion(),
traceSession: session,
);
final captured =
verify(
() => process.start(
dartBinaryFile.path,
captureAny(),
workingDirectory: any(named: 'workingDirectory'),
),
).captured.single
as List<String>;
// Expect: dart run <aotTools> --trace=<path> --version
expect(captured.length, 4);
expect(captured[0], 'run');
expect(captured[1], 'aot-tools.dill');
expect(captured[2], '--trace=${traceFile.path}');
expect(captured[3], '--version');
},
);
test(
'omits --trace when session has no trace file set',
() async {
when(
() => process.start(
any(),
any(),
workingDirectory: any(named: 'workingDirectory'),
),
).thenAnswer((_) async {
final mockProcess = MockProcess();
when(() => mockProcess.exitCode).thenAnswer((_) async => 0);
when(
() => mockProcess.stdout,
).thenAnswer((_) => Stream.value(utf8.encode('1.2.3')));
when(
() => mockProcess.stderr,
).thenAnswer((_) => const Stream<List<int>>.empty());
return mockProcess;
});
await runWithOverrides(() => aotTools.getVersion());
final captured =
verify(
() => process.start(
dartBinaryFile.path,
captureAny(),
workingDirectory: any(named: 'workingDirectory'),
),
).captured.single
as List<String>;
expect(captured.any((a) => a.startsWith('--trace=')), isFalse);
},
);
});
}); });
} }
@@ -1,4 +1,3 @@
import 'package:http/retry.dart';
import 'package:scoped_deps/scoped_deps.dart'; import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/http_client/http_client.dart'; import 'package:shorebird_cli/src/http_client/http_client.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
@@ -7,7 +6,7 @@ void main() {
group('scoped', () { group('scoped', () {
test('creates instance with default constructor', () { test('creates instance with default constructor', () {
final instance = runScoped(() => httpClient, values: {httpClientRef}); final instance = runScoped(() => httpClient, values: {httpClientRef});
expect(instance, isA<RetryClient>()); expect(instance, isA<TracingClient>());
}); });
}); });
} }
@@ -0,0 +1,109 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:mocktail/mocktail.dart';
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/artifact_builder/shorebird_tracer.dart';
import 'package:shorebird_cli/src/http_client/tracing_client.dart';
import 'package:test/test.dart';
class _MockHttpClient extends Mock implements http.Client {}
class _FakeBaseRequest extends Fake implements http.BaseRequest {}
void main() {
setUpAll(() {
registerFallbackValue(_FakeBaseRequest());
});
group(TracingClient, () {
late http.Client inner;
late ShorebirdTracer tracer;
late TracingClient client;
R runWithTracer<R>(R Function() body) => runScoped(
body,
values: {shorebirdTracerRef.overrideWith(() => tracer)},
);
setUp(() {
inner = _MockHttpClient();
tracer = ShorebirdTracer();
client = TracingClient(httpClient: inner);
});
http.StreamedResponse streamed({
int statusCode = 200,
String body = 'ok',
}) => http.StreamedResponse(
Stream.value(utf8.encode(body)),
statusCode,
);
test('records a network event on success', () async {
when(() => inner.send(any())).thenAnswer((_) async => streamed());
await runWithTracer(() async {
final req = http.Request(
'GET',
Uri.parse('https://api.example.com/v1'),
);
final response = await client.send(req);
await response.stream.drain<void>();
});
expect(tracer.events, hasLength(1));
final event = tracer.events.single;
expect(event['name'], 'GET api.example.com');
expect(event['cat'], 'network');
expect((event['args']! as Map)['method'], 'GET');
expect((event['args']! as Map)['host'], 'api.example.com');
expect((event['args']! as Map)['status'], 200);
});
test('records a network event even when inner throws', () async {
when(() => inner.send(any())).thenThrow(http.ClientException('boom'));
await runWithTracer(() async {
final req = http.Request(
'POST',
Uri.parse('https://api.example.com/v1'),
);
await expectLater(
client.send(req),
throwsA(isA<http.ClientException>()),
);
});
expect(tracer.events, hasLength(1));
final event = tracer.events.single;
expect(event['name'], 'POST api.example.com');
expect(event['cat'], 'network');
// Status is omitted when the request didn't complete.
expect((event['args']! as Map).containsKey('status'), isFalse);
expect((event['args']! as Map)['method'], 'POST');
});
test(
'records contentLength when the request provides one',
() async {
when(() => inner.send(any())).thenAnswer((_) async => streamed());
await runWithTracer(() async {
final req = http.Request(
'POST',
Uri.parse('https://api.example.com/v1'),
)..body = 'hello';
final response = await client.send(req);
await response.stream.drain<void>();
});
expect(
(tracer.events.single['args']! as Map)['contentLength'],
5,
);
},
);
});
}
@@ -454,6 +454,20 @@ void main() {
), ),
).called(1); ).called(1);
}); });
test('invokes onStart with the spawned process', () async {
Process? received;
final exit = await runWithOverrides(
() => shorebirdProcess.stream(
'git',
['pull'],
onStart: (p) => received = p,
),
);
expect(exit, ExitCode.success.code);
expect(identical(received, streamProcess), isTrue);
});
}); });
group('start', () { group('start', () {
+1
View File
@@ -10,6 +10,7 @@ workspace:
- packages/jwt - packages/jwt
- packages/redis_client - packages/redis_client
- packages/scoped_deps - packages/scoped_deps
- packages/shorebird_build_trace
- packages/shorebird_cli - packages/shorebird_cli
- packages/shorebird_code_push_client - packages/shorebird_code_push_client
- packages/shorebird_code_push_protocol - packages/shorebird_code_push_protocol