diff --git a/.gitignore b/.gitignore index 87264043..7bb37194 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ bin/cache/ # Misc .DS_Store +.claude/ diff --git a/bin/internal/flutter.version b/bin/internal/flutter.version index f7963962..2dc34fa4 100644 --- a/bin/internal/flutter.version +++ b/bin/internal/flutter.version @@ -1 +1 @@ -c48bfb11b86ed1d1215886f18d87d6fcd01df2e7 +3b10eecea184bb381f1045a878eeff36548ed11e diff --git a/cspell.config.yaml b/cspell.config.yaml index fd2d6765..4f55c12c 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -7,6 +7,7 @@ words: - aapt - aarch - aars # Android Archive + - aidl # Android AIDL interface definition files - allprojects # From gradle files - altool - ANDROIDD @@ -20,17 +21,21 @@ words: - bdero - bintools - bitcode + - bitrise # Bitrise CI platform - bryanoltman - bundletool - Dalvik # Android Dalvik VM / DEX file format - canvaskit - carryforward + - ccache - cipd + - codegen - codemagic - codesign - codesigned - codesigning - deflavored + - develocity - devicectl - dorny # From .github dir, doesn't show up in "**" check? - dyld @@ -48,6 +53,7 @@ words: - hotreload - idevicesyslog - incrbyfloat # From ./packages/redis_client + - ints - iokit - iphoneos - keyalg # From .github/workflows/e2e.yaml @@ -82,6 +88,7 @@ words: - parseable - patchability - pana + - Perfetto # Chrome trace viewer at ui.perfetto.dev - pkcs - podfile - podspec @@ -89,6 +96,7 @@ words: - precache - previewable - PRNG + - proguard # Android ProGuard shrinker/obfuscator - propertylistserialization - propertylistserialization - protos # Protocol buffer generated files @@ -98,8 +106,10 @@ words: - rdata - reactivecircus # From .github dir, doesn't show up in "**" check? - readlink + - reimplementation - reinit - requirepass # From .github dir, doesn't show up in "**" check? + - spawnee - Retryable - RSAPKCS - sdcard # From .github/workflows/e2e.yaml @@ -120,6 +130,7 @@ words: - sysroot - tdigest - temurin # From .github dir, doesn't show up in "**" check? + - timebase # Mach timebase (mach_timebase_info) - udevadm # From .github/workflows/e2e.yaml - udid # Unique Device Identifier - unawaited @@ -142,6 +153,8 @@ words: - xcframework - xcodebuild - xcodeproj + - xcresult + - xcresulttool - xcrun - xcscheme - xcschemes diff --git a/packages/shorebird_build_trace/analysis_options.yaml b/packages/shorebird_build_trace/analysis_options.yaml new file mode 100644 index 00000000..9df80aa4 --- /dev/null +++ b/packages/shorebird_build_trace/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.yaml diff --git a/packages/shorebird_build_trace/lib/shorebird_build_trace.dart b/packages/shorebird_build_trace/lib/shorebird_build_trace.dart new file mode 100644 index 00000000..5c84eb29 --- /dev/null +++ b/packages/shorebird_build_trace/lib/shorebird_build_trace.dart @@ -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'; diff --git a/packages/shorebird_build_trace/lib/src/build_trace_event.dart b/packages/shorebird_build_trace/lib/src/build_trace_event.dart new file mode 100644 index 00000000..d0dc0377 --- /dev/null +++ b/packages/shorebird_build_trace/lib/src/build_trace_event.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 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?, + ); + } + + /// 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? args; + + /// JSON form of the event. + Map toJson() => { + 'ph': 'X', + 'name': name, + 'cat': cat, + 'ts': start.microsecondsSinceEpoch, + 'dur': duration.inMicroseconds, + 'pid': pid, + 'tid': tid, + if (args != null) 'args': args, + }; +} diff --git a/packages/shorebird_build_trace/lib/src/build_tracer.dart b/packages/shorebird_build_trace/lib/src/build_tracer.dart new file mode 100644 index 00000000..ef4bb472 --- /dev/null +++ b/packages/shorebird_build_trace/lib/src/build_tracer.dart @@ -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 runAsync( + BuildTracer tracer, + Future 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> _events = >[]; + + /// 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> 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? 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({ + 'name': 'process_name', + 'ph': 'M', + 'pid': pid, + 'args': {'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({ + 'name': 'thread_name', + 'ph': 'M', + 'pid': pid, + 'tid': tid, + 'args': {'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({ + '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({ + '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()`. Exceptions + /// propagate; the span is still recorded via a try/finally. + T trace({ + required String name, + required String cat, + required int pid, + required int tid, + required T Function() body, + Map? 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 traceAsync({ + required String name, + required String cat, + required int pid, + required int tid, + required Future Function() body, + Map? 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 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: {'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 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: {'argv': arguments}, + ); + return result; + } + + /// Async variant of [timeSubprocess] for callers that use + /// [Process.run] or `processManager.run`. + Future timeSubprocessAsync({ + required String executable, + required List arguments, + required int pid, + required int tid, + required Future 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: {'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 startAndTraceSubprocess({ + required String executable, + required List arguments, + String? workingDirectory, + Map? 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: { + '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) { + _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>? existingEvents, + }) { + final merged = >[]; + 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) { + 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); +} diff --git a/packages/shorebird_build_trace/lib/src/phase_tracker.dart b/packages/shorebird_build_trace/lib/src/phase_tracker.dart new file mode 100644 index 00000000..6567d123 --- /dev/null +++ b/packages/shorebird_build_trace/lib/src/phase_tracker.dart @@ -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); +} diff --git a/packages/shorebird_build_trace/lib/src/process_id.dart b/packages/shorebird_build_trace/lib/src/process_id.dart new file mode 100644 index 00000000..dbf9faa1 --- /dev/null +++ b/packages/shorebird_build_trace/lib/src/process_id.dart @@ -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; diff --git a/packages/shorebird_build_trace/lib/src/run_subprocess.dart b/packages/shorebird_build_trace/lib/src/run_subprocess.dart new file mode 100644 index 00000000..d4bffb19 --- /dev/null +++ b/packages/shorebird_build_trace/lib/src/run_subprocess.dart @@ -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 runSubprocess( + String executable, + List arguments, { + String? workingDirectory, + Map? 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, + ); +} diff --git a/packages/shorebird_build_trace/lib/src/trace_schema.dart b/packages/shorebird_build_trace/lib/src/trace_schema.dart new file mode 100644 index 00000000..63f0c2d4 --- /dev/null +++ b/packages/shorebird_build_trace/lib/src/trace_schema.dart @@ -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 ` + /// 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 = apk / appbundle / ios / ipa). + /// shorebird matches with `startsWith`. + static const String flutterBuildSpanPrefix = 'flutter build '; + + /// Prefix for the outer gradle span. Span name is + /// `"gradle "`. + static const String gradleSpanPrefix = 'gradle '; + + /// Prefix for the outer xcode span. Span name is + /// `"xcode "` (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: "`. + 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: "`). + 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; + } +} diff --git a/packages/shorebird_build_trace/pubspec.yaml b/packages/shorebird_build_trace/pubspec.yaml new file mode 100644 index 00000000..6f39e403 --- /dev/null +++ b/packages/shorebird_build_trace/pubspec.yaml @@ -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 diff --git a/packages/shorebird_build_trace/test/build_tracer_test.dart b/packages/shorebird_build_trace/test/build_tracer_test.dart new file mode 100644 index 00000000..51255e55 --- /dev/null +++ b/packages/shorebird_build_trace/test/build_tracer_test.dart @@ -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; + 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; + 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; + 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 records a span around a sync body', () { + final t = BuildTracer(); + final result = t.trace( + name: 'work', + cat: 'c', + pid: 1, + tid: 1, + body: () => 42, + ); + expect(result, 42); + expect(t.eventCount, 1); + }); + + test('trace records a span even when body throws', () { + final t = BuildTracer(); + expect( + () => t.trace( + name: 'work', + cat: 'c', + pid: 1, + tid: 1, + body: () => throw StateError('boom'), + ), + throwsA(isA()), + ); + expect(t.eventCount, 1); + }); + + test('traceAsync records a span around an async body', () async { + final t = BuildTracer(); + final result = await t.traceAsync( + 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; + 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()), + ); + 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; + 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()), + ); + }); + + 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(t, () async { + throw StateError('boom'); + }), + throwsA(isA()), + ); + 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; + 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; + 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 = >>{}; + 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); + }); + }); +} diff --git a/packages/shorebird_build_trace/test/run_subprocess_test.dart b/packages/shorebird_build_trace/test/run_subprocess_test.dart new file mode 100644 index 00000000..1331f349 --- /dev/null +++ b/packages/shorebird_build_trace/test/run_subprocess_test.dart @@ -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(['M', 'M', 'X'])); + for (final e in tracer.events) { + expect(e['pid'], result.pid); + } + }, + ); + }); +} diff --git a/packages/shorebird_build_trace/test/trace_schema_test.dart b/packages/shorebird_build_trace/test/trace_schema_test.dart new file mode 100644 index 00000000..3c7e612e --- /dev/null +++ b/packages/shorebird_build_trace/test/trace_schema_test.dart @@ -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); + }); + }); +} diff --git a/packages/shorebird_cli/bin/shorebird.dart b/packages/shorebird_cli/bin/shorebird.dart index 058c59a0..a8d9fadd 100644 --- a/packages/shorebird_cli/bin/shorebird.dart +++ b/packages/shorebird_cli/bin/shorebird.dart @@ -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_studio.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/auth/auth.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'; Future main(List args) async { + final commandStartedAt = DateTime.now(); final loggingStdout = runScoped( () => LoggingStdout(baseStdOut: stdout, logFile: currentRunLogFile), values: {shorebirdEnvRef}, @@ -56,6 +59,9 @@ Command: shorebird ${args.join(' ')} appleRef, artifactBuilderRef, artifactManagerRef, + buildTraceSessionRef.overrideWith( + () => BuildTraceSession(commandStartedAt: commandStartedAt), + ), authRef, bundletoolRef, cacheRef, @@ -88,6 +94,7 @@ Command: shorebird ${args.join(' ')} shorebirdArtifactsRef, shorebirdEnvRef, shorebirdFlutterRef, + shorebirdTracerRef, shorebirdToolsRef, shorebirdValidatorRef, shorebirdVersionRef, diff --git a/packages/shorebird_cli/lib/src/artifact_builder/artifact_builder.dart b/packages/shorebird_cli/lib/src/artifact_builder/artifact_builder.dart index 37e06b44..66364193 100644 --- a/packages/shorebird_cli/lib/src/artifact_builder/artifact_builder.dart +++ b/packages/shorebird_cli/lib/src/artifact_builder/artifact_builder.dart @@ -1,5 +1,6 @@ // cspell:words endtemplate aabs ipas appbundle bryanoltman codesign xcarchive // cspell:words xcframework +import 'dart:convert'; import 'dart:io'; import 'package:clock/clock.dart'; @@ -7,14 +8,20 @@ import 'package:collection/collection.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:path/path.dart' as p; 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/logging/logging.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/shorebird_android_artifacts.dart'; import 'package:shorebird_cli/src/shorebird_artifacts.dart'; import 'package:shorebird_cli/src/shorebird_documentation.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'; /// {@template artifact_build_exception} @@ -109,6 +116,7 @@ ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/new'))} await _runShorebirdBuildCommand(() async { const executable = 'flutter'; final targetPlatformArgs = targetPlatforms?.targetPlatformArg; + final traceFile = buildTraceSession.traceFile; final arguments = [ 'build', 'appbundle', @@ -116,6 +124,7 @@ ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/new'))} if (flavor != null) '--flavor=$flavor', if (target != null) '--target=$target', if (targetPlatformArgs != null) '--target-platform=$targetPlatformArgs', + if (traceFile != null) '--shorebird-trace=${traceFile.path}', ...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 // executable path. runInShell: false, + onStart: _emitFlutterSpawnFlow, ); if (exitCode != ExitCode.success.code) { @@ -174,6 +184,7 @@ Reason: Exited with code $exitCode.''', await _runShorebirdBuildCommand(() async { const executable = 'flutter'; final targetPlatformArgs = targetPlatforms?.targetPlatformArg; + final traceFile = buildTraceSession.traceFile; final arguments = [ 'build', 'apk', @@ -186,6 +197,7 @@ Reason: Exited with code $exitCode.''', // coverage:ignore-start if (splitPerAbi) '--split-per-abi', // coverage:ignore-end + if (traceFile != null) '--shorebird-trace=${traceFile.path}', ...args, ]; @@ -196,6 +208,7 @@ Reason: Exited with code $exitCode.''', // Never run in shell because we always have a fully resolved // executable path. runInShell: false, + onStart: _emitFlutterSpawnFlow, ); if (exitCode != ExitCode.success.code) { @@ -241,6 +254,7 @@ Reason: Exited with code $exitCode.''', return _runShorebirdBuildCommand(() async { const executable = 'flutter'; final targetPlatformArgs = targetPlatforms?.targetPlatformArg; + final traceFile = buildTraceSession.traceFile; final arguments = [ 'build', 'aar', @@ -248,6 +262,7 @@ Reason: Exited with code $exitCode.''', '--no-profile', '--build-number=$buildNumber', if (targetPlatformArgs != null) '--target-platform=$targetPlatformArgs', + if (traceFile != null) '--shorebird-trace=${traceFile.path}', ...args, ]; @@ -258,6 +273,7 @@ Reason: Exited with code $exitCode.''', // Never run in shell because we always have a fully resolved // executable path. runInShell: false, + onStart: _emitFlutterSpawnFlow, ); if (exitCode != ExitCode.success.code) { @@ -283,11 +299,13 @@ Reason: Exited with code $exitCode.''', }) async { await _runShorebirdBuildCommand(() async { const executable = 'flutter'; + final traceFile = buildTraceSession.traceFile; final arguments = [ 'build', 'linux', '--release', if (target != null) '--target=$target', + if (traceFile != null) '--shorebird-trace=${traceFile.path}', ...args, ]; @@ -298,6 +316,7 @@ Reason: Exited with code $exitCode.''', // Never run in shell because we always have a fully resolved // executable path. runInShell: false, + onStart: _emitFlutterSpawnFlow, ); if (exitCode != ExitCode.success.code) { @@ -334,6 +353,7 @@ Reason: Exited with code $exitCode.''', String? appDillPath; await _runShorebirdBuildCommand(() async { const executable = 'flutter'; + final traceFile = buildTraceSession.traceFile; final arguments = [ 'build', 'macos', @@ -341,6 +361,7 @@ Reason: Exited with code $exitCode.''', if (flavor != null) '--flavor=$flavor', if (target != null) '--target=$target', if (!codesign) '--no-codesign', + if (traceFile != null) '--shorebird-trace=${traceFile.path}', ...args, ]; final buildStart = clock.now(); @@ -351,6 +372,7 @@ Reason: Exited with code $exitCode.''', // Never run in shell because we always have a fully resolved // executable path. runInShell: false, + onStart: _emitFlutterSpawnFlow, ); if (exitCode != ExitCode.success.code) { @@ -399,6 +421,7 @@ Reason: Exited with code $exitCode.''', String? appDillPath; await _runShorebirdBuildCommand(() async { const executable = 'flutter'; + final traceFile = buildTraceSession.traceFile; final arguments = [ 'build', 'ipa', @@ -406,6 +429,7 @@ Reason: Exited with code $exitCode.''', if (flavor != null) '--flavor=$flavor', if (target != null) '--target=$target', if (!codesign) '--no-codesign', + if (traceFile != null) '--shorebird-trace=${traceFile.path}', ...args, ]; @@ -417,6 +441,7 @@ Reason: Exited with code $exitCode.''', // Never run in shell because we always have a fully resolved // executable path. runInShell: false, + onStart: _emitFlutterSpawnFlow, ); if (exitCode != ExitCode.success.code) { @@ -460,11 +485,13 @@ Reason: Exited with code $exitCode.''', String? appDillPath; await _runShorebirdBuildCommand(() async { const executable = 'flutter'; + final traceFile = buildTraceSession.traceFile; final arguments = [ 'build', 'ios-framework', '--no-debug', '--no-profile', + if (traceFile != null) '--shorebird-trace=${traceFile.path}', ...args, ]; @@ -476,6 +503,7 @@ Reason: Exited with code $exitCode.''', // Never run in shell because we always have a fully resolved // executable path. runInShell: false, + onStart: _emitFlutterSpawnFlow, ); if (exitCode != ExitCode.success.code) { @@ -504,6 +532,147 @@ Reason: Exited with code $exitCode.''', 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 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--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 /// Shorebird's fork of Flutter) with a try/finally that runs /// `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, ]; - final exitCode = await process.stream( - shorebirdArtifacts.getArtifactPath(artifact: genSnapshotArtifact), - arguments, - // Never run in shell because we always have a fully resolved - // executable path. - runInShell: false, + // Record a span on the shorebird_cli row so gen_snapshot time shows up + // in Perfetto and rolls into the trace summary's subprocess bucket. + // gen_snapshot itself doesn't emit a trace; this is the best signal we + // have for how long native codegen took during patching. + final exitCode = await shorebirdTracer.span( + 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) { @@ -585,11 +762,13 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod }) async { await _runShorebirdBuildCommand(() async { const executable = 'flutter'; + final traceFile = buildTraceSession.traceFile; final arguments = [ 'build', 'windows', '--release', if (target != null) '--target=$target', + if (traceFile != null) '--shorebird-trace=${traceFile.path}', ...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 // executable path. runInShell: false, + onStart: _emitFlutterSpawnFlow, ); if (exitCode != ExitCode.success.code) { diff --git a/packages/shorebird_cli/lib/src/artifact_builder/build_environment.dart b/packages/shorebird_cli/lib/src/artifact_builder/build_environment.dart new file mode 100644 index 00000000..67dd48bf --- /dev/null +++ b/packages/shorebird_cli/lib/src/artifact_builder/build_environment.dart @@ -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 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 rest) { + if (base == null) return null; + return File(p.joinAll([base, ...rest])); + } + + final gradleProps = { + ..._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 `?? `); 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 toJson() => { + 'isCi': isCi, + 'ciProvider': ciProvider, + 'gradle': { + 'buildCacheEnabled': gradleBuildCacheEnabled, + 'configurationCacheEnabled': gradleConfigurationCacheEnabled, + 'parallelEnabled': gradleParallelEnabled, + 'daemonEnabled': gradleDaemonEnabled, + 'develocityDetected': gradleDevelocityDetected, + 'initScriptCount': gradleInitScriptCount, + }, + 'ios': {'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 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 _readPropsFile(File? file) { + if (file == null || !file.existsSync()) return const {}; + final out = {}; + 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(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() + .where( + (f) => f.path.endsWith('.gradle') || f.path.endsWith('.gradle.kts'), + ) + .length; + } + + static bool _detectCcache(Map 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; + } +} diff --git a/packages/shorebird_cli/lib/src/artifact_builder/build_trace_session.dart b/packages/shorebird_cli/lib/src/artifact_builder/build_trace_session.dart new file mode 100644 index 00000000..128b9265 --- /dev/null +++ b/packages/shorebird_cli/lib/src/artifact_builder/build_trace_session.dart @@ -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(commandStartedAt: DateTime.now()), +); + +/// The [BuildTraceSession] instance available in the current zone. +BuildTraceSession get buildTraceSession => read(buildTraceSessionRef); diff --git a/packages/shorebird_cli/lib/src/artifact_builder/build_trace_summary.dart b/packages/shorebird_cli/lib/src/artifact_builder/build_trace_summary.dart new file mode 100644 index 00000000..5fe50d89 --- /dev/null +++ b/packages/shorebird_cli/lib/src/artifact_builder/build_trace_summary.dart @@ -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> 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 e) { + final dur = Duration(microseconds: (e['dur'] as num?)?.toInt() ?? 0); + final name = (e['name'] as String?) ?? ''; + final args = + (e['args'] as Map?) ?? const {}; + 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 ` 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 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>? 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>(); + } 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 toJson() => { + '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': {'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 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 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 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 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 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 toJson() => { + '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 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 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 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 = {}; + final podPhase = {}; + + int gradleTaskFromCacheCount = 0; + int gradleTaskUpToDateCount = 0; + int gradleTaskExecutedCount = 0; + final gradleTaskDurations = []; + // Xcode subsection titles are high-variance ("Build target ", + // "Archive target ", etc.) so the summary keeps aggregates and + // a histogram rather than name-keyed totals. + final xcodeSubsectionDurations = []; +} + +extension on Map { + /// Adds [value] to the counter keyed by [key], initializing from + /// [Duration.zero]. + void add(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 key) => this[key] ?? Duration.zero; +} diff --git a/packages/shorebird_cli/lib/src/artifact_builder/duration_distribution.dart b/packages/shorebird_cli/lib/src/artifact_builder/duration_distribution.dart new file mode 100644 index 00000000..0dee0b40 --- /dev/null +++ b/packages/shorebird_cli/lib/src/artifact_builder/duration_distribution.dart @@ -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 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 toJson() => { + 'count': count, + 'sumMs': sum.inMilliseconds, + 'p50Ms': p50.inMilliseconds, + 'p90Ms': p90.inMilliseconds, + 'maxMs': max.inMilliseconds, + }; +} diff --git a/packages/shorebird_cli/lib/src/artifact_builder/shorebird_tracer.dart b/packages/shorebird_cli/lib/src/artifact_builder/shorebird_tracer.dart new file mode 100644 index 00000000..f38da96c --- /dev/null +++ b/packages/shorebird_cli/lib/src/artifact_builder/shorebird_tracer.dart @@ -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> 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? 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 span({ + required String name, + required String category, + required Future Function() body, + Map? args, + }) => _tracer.traceAsync( + 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> existingEvents; + try { + final decoded = jsonDecode(traceFile.readAsStringSync()); + if (decoded is! List) return; + existingEvents = decoded.whereType>().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.new); + +/// The [ShorebirdTracer] instance available in the current zone. +ShorebirdTracer get shorebirdTracer => read(shorebirdTracerRef); diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart index e8fa6548..02369160 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart @@ -5,6 +5,7 @@ import 'package:path/path.dart' as p; import 'package:meta/meta.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/build_trace_session.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/cache.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 { 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. if (lastBuiltFlutterRevision != release.flutterRevision) { final flutterVersionString = await shorebirdFlutter @@ -535,6 +544,12 @@ Building patch with Flutter $flutterVersionString 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( releasePlatform: patcher.releaseType.releasePlatform, usedIgnoreAssetChangesFlag: allowAssetDiffs, @@ -554,6 +569,11 @@ Building patch with Flutter $flutterVersionString 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( baseMetadata, diff --git a/packages/shorebird_cli/lib/src/commands/release/release_command.dart b/packages/shorebird_cli/lib/src/commands/release/release_command.dart index f7aef42a..05f81e7c 100644 --- a/packages/shorebird_cli/lib/src/commands/release/release_command.dart +++ b/packages/shorebird_cli/lib/src/commands/release/release_command.dart @@ -5,6 +5,7 @@ import 'package:mason_logger/mason_logger.dart'; import 'package:meta/meta.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/build_trace_session.dart'; import 'package:shorebird_cli/src/cache.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.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 { 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 .getVersionAndRevision(); logger.info( @@ -582,6 +592,11 @@ ${summary.join('\n')} required Release release, required Releaser releaser, }) 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 = results.wasParsed(CommonArguments.publicKeyArg.name) || results.wasParsed(CommonArguments.publicKeyCmd.name); @@ -597,6 +612,10 @@ ${summary.join('\n')} shorebirdYaml: shorebirdEnv.getShorebirdYaml()!, 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); await codePushClientWrapper.updateReleaseStatus( diff --git a/packages/shorebird_cli/lib/src/executables/aot_tools.dart b/packages/shorebird_cli/lib/src/executables/aot_tools.dart index 2754b779..4111bd92 100644 --- a/packages/shorebird_cli/lib/src/executables/aot_tools.dart +++ b/packages/shorebird_cli/lib/src/executables/aot_tools.dart @@ -7,6 +7,7 @@ import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; import 'package:pub_semver/pub_semver.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/engine_config.dart'; import 'package:shorebird_cli/src/extensions/version.dart'; @@ -129,6 +130,17 @@ class AotTools { }) async { 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 = [ + if (traceFile != null) '--trace=${traceFile.path}', + ...command, + ]; + // This will be a path to either a kernel (.dill) file or a Dart script if // we're running with a local engine. final artifactPath = shorebirdArtifacts.getArtifactPath( @@ -185,7 +197,7 @@ class AotTools { if (extension != '.dill' && extension != '.dart') { result = await execute( artifactPath, - command, + tracedCommand, workingDirectory: workingDirectory, ); } else { @@ -193,7 +205,7 @@ class AotTools { result = await execute(shorebirdEnv.dartBinaryFile.path, [ 'run', artifactPath, - ...command, + ...tracedCommand, ], workingDirectory: workingDirectory); } @@ -202,7 +214,7 @@ class AotTools { exitCode: result.exitCode, stdout: result.stdout.toString(), stderr: result.stderr.toString(), - command: ['aot_tools', ...command].join(' '), + command: ['aot_tools', ...tracedCommand].join(' '), ); } diff --git a/packages/shorebird_cli/lib/src/flutter_version_constraints.dart b/packages/shorebird_cli/lib/src/flutter_version_constraints.dart index d7aa3bbe..7d46bdd8 100644 --- a/packages/shorebird_cli/lib/src/flutter_version_constraints.dart +++ b/packages/shorebird_cli/lib/src/flutter_version_constraints.dart @@ -29,3 +29,56 @@ final minimumSupportedWindowsFlutterVersion = Version(3, 32, 6); /// Obfuscation requires gen_snapshot changes (--save-obfuscation-map and /// --strip flags) that were first available in this Flutter version. 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 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=` 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', + }, +); diff --git a/packages/shorebird_cli/lib/src/http_client/http_client.dart b/packages/shorebird_cli/lib/src/http_client/http_client.dart index 52e7d5b4..675061f4 100644 --- a/packages/shorebird_cli/lib/src/http_client/http_client.dart +++ b/packages/shorebird_cli/lib/src/http_client/http_client.dart @@ -2,13 +2,17 @@ import 'package:http/http.dart' as http; import 'package:scoped_deps/scoped_deps.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/tracing_client.dart'; export 'logging_client.dart'; export 'retrying_client.dart'; +export 'tracing_client.dart'; /// A reference to a [http.Client] instance. -final httpClientRef = create( - () => retryingHttpClient(LoggingClient(httpClient: http.Client())), +final httpClientRef = create( + () => TracingClient( + httpClient: retryingHttpClient(LoggingClient(httpClient: http.Client())), + ), ); /// The [http.Client] instance available in the current zone. diff --git a/packages/shorebird_cli/lib/src/http_client/tracing_client.dart b/packages/shorebird_cli/lib/src/http_client/tracing_client.dart new file mode 100644 index 00000000..d3d9b143 --- /dev/null +++ b/packages/shorebird_cli/lib/src/http_client/tracing_client.dart @@ -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 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, + }, + ); + } + } +} diff --git a/packages/shorebird_cli/lib/src/metadata/create_patch_metadata.dart b/packages/shorebird_cli/lib/src/metadata/create_patch_metadata.dart index 914ec13e..c5409a1e 100644 --- a/packages/shorebird_cli/lib/src/metadata/create_patch_metadata.dart +++ b/packages/shorebird_cli/lib/src/metadata/create_patch_metadata.dart @@ -29,6 +29,7 @@ class CreatePatchMetadata extends Equatable { required this.isSigned, this.linkPercentage, this.linkMetadata, + this.buildTraceSummary, }); // coverage:ignore-start @@ -44,6 +45,7 @@ class CreatePatchMetadata extends Equatable { bool isSigned = false, double? linkPercentage, Json? linkMetadata, + Json? buildTraceSummary, BuildEnvironmentMetadata? environment, }) => CreatePatchMetadata( releasePlatform: releasePlatform, @@ -55,6 +57,7 @@ class CreatePatchMetadata extends Equatable { inferredReleaseVersion: inferredReleaseVersion, linkPercentage: linkPercentage, linkMetadata: linkMetadata, + buildTraceSummary: buildTraceSummary, environment: environment ?? BuildEnvironmentMetadata.forTest(), ); // coverage:ignore-end @@ -78,6 +81,7 @@ class CreatePatchMetadata extends Equatable { bool? isSigned, double? linkPercentage, Json? linkMetadata, + Json? buildTraceSummary, BuildEnvironmentMetadata? environment, }) => CreatePatchMetadata( releasePlatform: releasePlatform ?? this.releasePlatform, @@ -92,6 +96,7 @@ class CreatePatchMetadata extends Equatable { isSigned: isSigned ?? this.isSigned, linkPercentage: linkPercentage ?? this.linkPercentage, linkMetadata: linkMetadata ?? this.linkMetadata, + buildTraceSummary: buildTraceSummary ?? this.buildTraceSummary, environment: environment ?? this.environment, ); @@ -149,6 +154,15 @@ class CreatePatchMetadata extends Equatable { /// Reason: see [BuildEnvironmentMetadata]. 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 List get props => [ releasePlatform, @@ -161,5 +175,6 @@ class CreatePatchMetadata extends Equatable { inferredReleaseVersion, isSigned, environment, + buildTraceSummary, ]; } diff --git a/packages/shorebird_cli/lib/src/metadata/create_patch_metadata.g.dart b/packages/shorebird_cli/lib/src/metadata/create_patch_metadata.g.dart index eaaa54e3..eb8f636c 100644 --- a/packages/shorebird_cli/lib/src/metadata/create_patch_metadata.g.dart +++ b/packages/shorebird_cli/lib/src/metadata/create_patch_metadata.g.dart @@ -46,6 +46,10 @@ CreatePatchMetadata _$CreatePatchMetadataFromJson( 'link_metadata', (v) => v as Map?, ), + buildTraceSummary: $checkedConvert( + 'build_trace_summary', + (v) => v as Map?, + ), ); return val; }, @@ -59,6 +63,7 @@ CreatePatchMetadata _$CreatePatchMetadataFromJson( 'isSigned': 'is_signed', 'linkPercentage': 'link_percentage', 'linkMetadata': 'link_metadata', + 'buildTraceSummary': 'build_trace_summary', }, ); @@ -75,6 +80,7 @@ Map _$CreatePatchMetadataToJson( 'link_metadata': instance.linkMetadata, 'is_signed': instance.isSigned, 'environment': instance.environment.toJson(), + 'build_trace_summary': instance.buildTraceSummary, }; const _$ReleasePlatformEnumMap = { diff --git a/packages/shorebird_cli/lib/src/metadata/update_release_metadata.dart b/packages/shorebird_cli/lib/src/metadata/update_release_metadata.dart index baf5613d..214c7eb1 100644 --- a/packages/shorebird_cli/lib/src/metadata/update_release_metadata.dart +++ b/packages/shorebird_cli/lib/src/metadata/update_release_metadata.dart @@ -24,6 +24,7 @@ class UpdateReleaseMetadata extends Equatable { required this.environment, required this.includesPublicKey, this.generatedApks, + this.buildTraceSummary, }); // coverage:ignore-start @@ -35,12 +36,14 @@ class UpdateReleaseMetadata extends Equatable { bool? generatedApks = false, bool includesPublicKey = false, BuildEnvironmentMetadata? environment, + Json? buildTraceSummary, }) => UpdateReleaseMetadata( releasePlatform: releasePlatform, flutterVersionOverride: flutterVersionOverride, generatedApks: generatedApks, environment: environment ?? BuildEnvironmentMetadata.forTest(), includesPublicKey: includesPublicKey, + buildTraceSummary: buildTraceSummary, ); // coverage:ignore-end @@ -59,6 +62,7 @@ class UpdateReleaseMetadata extends Equatable { bool? generatedApks, BuildEnvironmentMetadata? environment, bool? includesPublicKey, + Json? buildTraceSummary, }) => UpdateReleaseMetadata( releasePlatform: releasePlatform ?? this.releasePlatform, flutterVersionOverride: @@ -66,6 +70,7 @@ class UpdateReleaseMetadata extends Equatable { generatedApks: generatedApks ?? this.generatedApks, environment: environment ?? this.environment, includesPublicKey: includesPublicKey ?? this.includesPublicKey, + buildTraceSummary: buildTraceSummary ?? this.buildTraceSummary, ); /// The platform for which the patch was created. @@ -97,6 +102,15 @@ class UpdateReleaseMetadata extends Equatable { /// Reason: see [BuildEnvironmentMetadata]. 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 List get props => [ releasePlatform, @@ -104,5 +118,6 @@ class UpdateReleaseMetadata extends Equatable { generatedApks, includesPublicKey, environment, + buildTraceSummary, ]; } diff --git a/packages/shorebird_cli/lib/src/metadata/update_release_metadata.g.dart b/packages/shorebird_cli/lib/src/metadata/update_release_metadata.g.dart index 38a67d4f..56101d73 100644 --- a/packages/shorebird_cli/lib/src/metadata/update_release_metadata.g.dart +++ b/packages/shorebird_cli/lib/src/metadata/update_release_metadata.g.dart @@ -32,6 +32,10 @@ UpdateReleaseMetadata _$UpdateReleaseMetadataFromJson( (v) => v as bool?, ), generatedApks: $checkedConvert('generated_apks', (v) => v as bool?), + buildTraceSummary: $checkedConvert( + 'build_trace_summary', + (v) => v as Map?, + ), ); return val; }, @@ -40,6 +44,7 @@ UpdateReleaseMetadata _$UpdateReleaseMetadataFromJson( 'flutterVersionOverride': 'flutter_version_override', 'includesPublicKey': 'includes_public_key', 'generatedApks': 'generated_apks', + 'buildTraceSummary': 'build_trace_summary', }, ); @@ -51,6 +56,7 @@ Map _$UpdateReleaseMetadataToJson( 'generated_apks': instance.generatedApks, 'includes_public_key': instance.includesPublicKey, 'environment': instance.environment.toJson(), + 'build_trace_summary': instance.buildTraceSummary, }; const _$ReleasePlatformEnumMap = { diff --git a/packages/shorebird_cli/lib/src/shorebird_process.dart b/packages/shorebird_cli/lib/src/shorebird_process.dart index 8595a97d..7baed975 100644 --- a/packages/shorebird_cli/lib/src/shorebird_process.dart +++ b/packages/shorebird_cli/lib/src/shorebird_process.dart @@ -36,6 +36,7 @@ class ShorebirdProcess { Map? environment, bool? runInShell, String? workingDirectory, + void Function(Process process)? onStart, }) async { final process = await start( executable, @@ -45,6 +46,7 @@ class ShorebirdProcess { workingDirectory: workingDirectory, mode: ProcessStartMode.inheritStdio, ); + onStart?.call(process); return process.exitCode; } diff --git a/packages/shorebird_cli/pubspec.yaml b/packages/shorebird_cli/pubspec.yaml index 055cda3a..a685505a 100644 --- a/packages/shorebird_cli/pubspec.yaml +++ b/packages/shorebird_cli/pubspec.yaml @@ -40,6 +40,8 @@ dependencies: pubspec_parse: ^1.5.0 retry: ^3.1.2 scoped_deps: ^0.1.0 + shorebird_build_trace: + path: ../shorebird_build_trace shorebird_code_push_client: path: ../shorebird_code_push_client shorebird_code_push_protocol: diff --git a/packages/shorebird_cli/test/src/artifact_builder/artifact_builder_test.dart b/packages/shorebird_cli/test/src/artifact_builder/artifact_builder_test.dart index 820c1f14..00811b76 100644 --- a/packages/shorebird_cli/test/src/artifact_builder/artifact_builder_test.dart +++ b/packages/shorebird_cli/test/src/artifact_builder/artifact_builder_test.dart @@ -1,10 +1,14 @@ +import 'dart:convert'; import 'dart:io'; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; +import 'package:pub_semver/pub_semver.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/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/logging/logging.dart'; import 'package:shorebird_cli/src/os/operating_system_interface.dart'; @@ -13,6 +17,7 @@ import 'package:shorebird_cli/src/shorebird_android_artifacts.dart'; import 'package:shorebird_cli/src/shorebird_artifacts.dart'; import 'package:shorebird_cli/src/shorebird_documentation.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:test/test.dart'; @@ -29,6 +34,7 @@ void main() { late ShorebirdAndroidArtifacts shorebirdAndroidArtifacts; late ShorebirdArtifacts shorebirdArtifacts; late ShorebirdEnv shorebirdEnv; + late ShorebirdFlutter shorebirdFlutter; late ShorebirdProcess shorebirdProcess; late ShorebirdProcessResult pubGetProcessResult; late ArtifactBuilder builder; @@ -39,11 +45,16 @@ void main() { values: { appleRef.overrideWith(() => apple), artifactManagerRef.overrideWith(() => artifactManager), + buildTraceSessionRef.overrideWith( + () => BuildTraceSession(commandStartedAt: DateTime.now()), + ), loggerRef.overrideWith(() => logger), osInterfaceRef.overrideWith(() => operatingSystemInterface), processRef.overrideWith(() => shorebirdProcess), shorebirdArtifactsRef.overrideWith(() => shorebirdArtifacts), shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), + shorebirdTracerRef.overrideWith(ShorebirdTracer.new), shorebirdAndroidArtifactsRef.overrideWith( () => shorebirdAndroidArtifacts, ), @@ -66,8 +77,16 @@ void main() { shorebirdAndroidArtifacts = MockShorebirdAndroidArtifacts(); shorebirdArtifacts = MockShorebirdArtifacts(); shorebirdEnv = MockShorebirdEnv(); + shorebirdFlutter = MockShorebirdFlutter(); shorebirdProcess = MockShorebirdProcess(); + // Default to a Flutter version that does not support --trace so + // existing exact-argument verifications aren't disturbed. Tests that + // exercise the trace path override this stub. + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 0, 0)); + when( () => shorebirdProcess.run('flutter', [ '--no-version-check', @@ -85,6 +104,7 @@ void main() { any(), environment: any(named: 'environment'), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.success.code); @@ -94,6 +114,9 @@ void main() { () => operatingSystemInterface.which('flutter'), ).thenReturn('/path/to/flutter'); when(() => shorebirdEnv.flutterRevision).thenReturn('1234'); + when( + () => shorebirdEnv.buildDirectory, + ).thenReturn(Directory(p.join(projectRoot.path, 'build'))); when(shorebirdEnv.getShorebirdProjectRoot).thenReturn(projectRoot); @@ -177,6 +200,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod ['build', 'appbundle', '--release'], environment: any(named: 'environment'), runInShell: false, + onStart: any(named: 'onStart'), ), ).called(1); }); @@ -192,16 +216,100 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod ); verify( - () => shorebirdProcess.stream('flutter', [ - 'build', - 'appbundle', - '--release', - '--flavor=flavor', - '--target=target', - '--target-platform=android-arm64', - '--foo', - 'bar', - ], runInShell: false), + () => shorebirdProcess.stream( + 'flutter', + [ + 'build', + 'appbundle', + '--release', + '--flavor=flavor', + '--target=target', + '--target-platform=android-arm64', + '--foo', + 'bar', + ], + runInShell: false, + onStart: any(named: 'onStart'), + ), + ).called(1); + }); + + test( + 'onStart callback records a flow-start keyed to the child pid', + () async { + // Flutter needs to advertise trace support so the onStart + // callback is wired in the first place. + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 7)); + + // Capture the onStart callback the builder hands to + // process.stream so we can invoke it with a fake child Process + // carrying a known pid. + void Function(Process)? capturedOnStart; + when( + () => shorebirdProcess.stream( + any(), + any(), + environment: any(named: 'environment'), + runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), + ), + ).thenAnswer((invocation) async { + capturedOnStart = + invocation.namedArguments[#onStart] as void Function(Process)?; + return ExitCode.success.code; + }); + + final child = MockProcess(); + when(() => child.pid).thenReturn(12345); + + await runWithOverrides(() async { + await builder.prepareBuildTrace(platform: 'android'); + await builder.buildAppBundle(); + expect(capturedOnStart, isNotNull); + capturedOnStart!(child); + + final flowStarts = shorebirdTracer.events + .where((e) => e['ph'] == 's') + .toList(); + expect(flowStarts, hasLength(1)); + expect(flowStarts.single['id'], 12345); + expect(flowStarts.single['cat'], 'flow'); + }); + }, + ); + + test('adds --trace when Flutter supports build tracing', () async { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 7)); + + await runWithOverrides(() async { + await builder.prepareBuildTrace(platform: 'android'); + await builder.buildAppBundle(); + }); + + final expectedTracePath = p.join( + projectRoot.path, + 'build', + 'shorebird', + 'debug', + 'build-trace-android.json', + ); + verify( + () => shorebirdProcess.stream( + 'flutter', + [ + 'build', + 'appbundle', + '--release', + '--shorebird-trace=$expectedTracePath', + ], + environment: any(named: 'environment'), + runInShell: false, + onStart: any(named: 'onStart'), + ), ).called(1); }); @@ -222,6 +330,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod ], environment: {'SHOREBIRD_PUBLIC_KEY': base64PublicKey}, runInShell: false, + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.success.code); }); @@ -249,6 +358,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod ], environment: {'SHOREBIRD_PUBLIC_KEY': base64PublicKey}, runInShell: false, + onStart: any(named: 'onStart'), ), ).called(1); }); @@ -320,6 +430,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod any(), any(), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.success.code); }); @@ -335,6 +446,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod any(), any(), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.software.code); }); @@ -379,6 +491,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod ['build', 'apk', '--release'], environment: any(named: 'environment'), runInShell: false, + onStart: any(named: 'onStart'), ), ).called(1); }); @@ -394,19 +507,177 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod ); verify( - () => shorebirdProcess.stream('flutter', [ - 'build', - 'apk', - '--release', - '--flavor=flavor', - '--target=target', - '--target-platform=android-arm64', - '--foo', - 'bar', - ], runInShell: false), + () => shorebirdProcess.stream( + 'flutter', + [ + 'build', + 'apk', + '--release', + '--flavor=flavor', + '--target=target', + '--target-platform=android-arm64', + '--foo', + 'bar', + ], + runInShell: false, + onStart: any(named: 'onStart'), + ), ).called(1); }); + group('when Flutter supports build tracing', () { + setUp(() { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 7)); + }); + + test( + 'passes --trace with a path under build/shorebird/debug', + () async { + await runWithOverrides(() async { + await builder.prepareBuildTrace(platform: 'android'); + await builder.buildApk(); + }); + + final expectedTracePath = p.join( + projectRoot.path, + 'build', + 'shorebird', + 'debug', + 'build-trace-android.json', + ); + verify( + () => shorebirdProcess.stream( + 'flutter', + [ + 'build', + 'apk', + '--release', + '--shorebird-trace=$expectedTracePath', + ], + environment: any(named: 'environment'), + runInShell: false, + onStart: any(named: 'onStart'), + ), + ).called(1); + expect( + Directory(p.dirname(expectedTracePath)).existsSync(), + isTrue, + ); + }, + ); + + test( + 'writes a summary JSON next to a trace file Flutter produced', + () async { + final traceFile = File( + p.join( + projectRoot.path, + 'build', + 'shorebird', + 'debug', + 'build-trace-android.json', + ), + ); + // Simulate Flutter writing the trace: create the file just before + // the build command would return. + when( + () => shorebirdProcess.stream( + any(), + any(), + environment: any(named: 'environment'), + runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), + ), + ).thenAnswer((_) async { + traceFile.parent.createSync(recursive: true); + traceFile.writeAsStringSync( + jsonEncode([ + { + 'ph': 'X', + 'name': 'pre-gradle setup', + 'cat': 'flutter', + 'ts': 1000, + 'dur': 100, + 'pid': 1, + 'tid': 1, + }, + { + 'ph': 'X', + 'name': 'gradle assembleRelease', + 'cat': 'gradle', + 'ts': 1100, + 'dur': 3_000_000, + 'pid': 1, + 'tid': 2, + }, + { + 'ph': 'X', + 'name': 'kernel_snapshot_program', + 'cat': 'assemble', + 'ts': 2000, + 'dur': 500_000, + 'pid': 1, + 'tid': 3, + }, + { + 'ph': 'X', + 'name': 'android_aot', + 'cat': 'assemble', + 'ts': 500000, + 'dur': 200_000, + 'pid': 1, + 'tid': 3, + }, + { + 'ph': 'X', + 'name': 'flutter build apk', + 'cat': 'flutter', + 'ts': 1000, + 'dur': 3_000_100, + 'pid': 1, + 'tid': 1, + }, + ]), + ); + return ExitCode.success.code; + }); + + await runWithOverrides(() async { + await builder.prepareBuildTrace(platform: 'android'); + await builder.buildApk(); + builder.writeBuildTraceSummary(); + }); + + final summaryFile = File( + p.join( + p.dirname(traceFile.path), + 'build-trace-android-summary.json', + ), + ); + expect(summaryFile.existsSync(), isTrue); + final summary = + jsonDecode(summaryFile.readAsStringSync()) + as Map; + expect(summary['platform'], 'android'); + expect(summary['version'], 8); + // 500ms kernel + 200ms aot + expect((summary['dart'] as Map)['totalMs'], 700); + expect(summary['flutterBuildMs'], 3000); + expect(summary['shorebirdOverheadMs'], isNonNegative); + final dart = summary['dart']! as Map; + expect(dart['kernelSnapshotMs'], 500); + expect(dart['genSnapshotMs'], 200); + final assemble = + summary['flutterAssemble']! as Map; + expect(assemble['targetCount'], 2); + expect(summary['android'], isA>()); + expect(summary.containsKey('ios'), isFalse); + }, + ); + }); + group('when base64PublicKey is not null', () { const base64PublicKey = 'base64PublicKey'; @@ -424,6 +695,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod ], environment: {'SHOREBIRD_PUBLIC_KEY': base64PublicKey}, runInShell: false, + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.success.code); }); @@ -451,6 +723,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod ], environment: {'SHOREBIRD_PUBLIC_KEY': base64PublicKey}, runInShell: false, + onStart: any(named: 'onStart'), ), ).called(1); }); @@ -522,6 +795,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod any(), any(), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.success.code); }); @@ -537,6 +811,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod any(), any(), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.software.code); }); @@ -565,6 +840,44 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod group('buildAar', () { const buildNumber = '1.0'; + test( + 'passes --shorebird-trace when Flutter supports build tracing', + () async { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 7)); + + await runWithOverrides(() async { + await builder.prepareBuildTrace(platform: 'android'); + await builder.buildAar(buildNumber: buildNumber); + }); + + final expectedTracePath = p.join( + projectRoot.path, + 'build', + 'shorebird', + 'debug', + 'build-trace-android.json', + ); + verify( + () => shorebirdProcess.stream( + 'flutter', + [ + 'build', + 'aar', + '--no-debug', + '--no-profile', + '--build-number=1.0', + '--shorebird-trace=$expectedTracePath', + ], + environment: any(named: 'environment'), + runInShell: false, + onStart: any(named: 'onStart'), + ), + ).called(1); + }, + ); + test('invokes the correct flutter build command', () async { await runWithOverrides( () => builder.buildAar(buildNumber: buildNumber), @@ -582,6 +895,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod ], environment: any(named: 'environment'), runInShell: false, + onStart: any(named: 'onStart'), ), ).called(1); }); @@ -596,16 +910,21 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod ); verify( - () => shorebirdProcess.stream('flutter', [ - 'build', - 'aar', - '--no-debug', - '--no-profile', - '--build-number=1.0', - '--target-platform=android-arm64', - '--foo', - 'bar', - ], runInShell: false), + () => shorebirdProcess.stream( + 'flutter', + [ + 'build', + 'aar', + '--no-debug', + '--no-profile', + '--build-number=1.0', + '--target-platform=android-arm64', + '--foo', + 'bar', + ], + runInShell: false, + onStart: any(named: 'onStart'), + ), ).called(1); }); @@ -632,6 +951,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod ], environment: {'SHOREBIRD_PUBLIC_KEY': base64PublicKey}, runInShell: false, + onStart: any(named: 'onStart'), ), ).called(1); }); @@ -645,6 +965,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod any(), any(), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.success.code); }); @@ -662,6 +983,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod any(), any(), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.software.code); }); @@ -715,6 +1037,42 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod ).thenAnswer((_) async => ExitCode.success.code); }); + test( + 'passes --shorebird-trace when Flutter supports build tracing', + () async { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 7)); + + await runWithOverrides(() async { + await builder.prepareBuildTrace(platform: 'linux'); + await builder.buildLinuxApp(); + }); + + final expectedTracePath = p.join( + projectRoot.path, + 'build', + 'shorebird', + 'debug', + 'build-trace-linux.json', + ); + verify( + () => shorebirdProcess.stream( + 'flutter', + [ + 'build', + 'linux', + '--release', + '--shorebird-trace=$expectedTracePath', + ], + environment: any(named: 'environment'), + runInShell: false, + onStart: any(named: 'onStart'), + ), + ).called(1); + }, + ); + group('when flutter build fails', () { setUp(() { when( @@ -722,6 +1080,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod any(), any(), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.software.code); }); @@ -760,12 +1119,17 @@ Reason: Exited with code 70.'''), ); verify( - () => shorebirdProcess.stream('flutter', [ - 'build', - 'linux', - '--release', - '--target=target.dart', - ], runInShell: false), + () => shorebirdProcess.stream( + 'flutter', + [ + 'build', + 'linux', + '--release', + '--target=target.dart', + ], + runInShell: false, + onStart: any(named: 'onStart'), + ), ).called(1); }); }); @@ -777,6 +1141,7 @@ Reason: Exited with code 70.'''), any(), any(), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.success.code); }); @@ -799,6 +1164,7 @@ Reason: Exited with code 70.'''), any(), environment: any(named: 'environment'), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.success.code); }); @@ -814,6 +1180,7 @@ Reason: Exited with code 70.'''), ['build', 'linux', '--release'], environment: {'SHOREBIRD_PUBLIC_KEY': publicKey}, runInShell: false, + onStart: any(named: 'onStart'), ), ).called(1); }); @@ -829,6 +1196,7 @@ Reason: Exited with code 70.'''), any(), environment: any(named: 'environment'), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async { appDill = File(p.join(projectRoot.path, '.dart_tool', 'app.dill')) @@ -841,6 +1209,42 @@ Reason: Exited with code 70.'''), }); }); + test( + 'passes --shorebird-trace when Flutter supports build tracing', + () async { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 7)); + + await runWithOverrides(() async { + await builder.prepareBuildTrace(platform: 'macos'); + await builder.buildMacos(); + }); + + final expectedTracePath = p.join( + projectRoot.path, + 'build', + 'shorebird', + 'debug', + 'build-trace-macos.json', + ); + verify( + () => shorebirdProcess.stream( + 'flutter', + [ + 'build', + 'macos', + '--release', + '--shorebird-trace=$expectedTracePath', + ], + environment: any(named: 'environment'), + runInShell: false, + onStart: any(named: 'onStart'), + ), + ).called(1); + }, + ); + group('when .dart_tool directory exists', () { late File foo; setUp(() { @@ -865,6 +1269,7 @@ Reason: Exited with code 70.'''), ['build', 'macos', '--release'], environment: any(named: 'environment'), runInShell: false, + onStart: any(named: 'onStart'), ), ).called(1); expect(result.kernelFile.path, equals(appDill.path)); @@ -885,6 +1290,7 @@ Reason: Exited with code 70.'''), ['build', 'macos', '--release'], environment: {'SHOREBIRD_PUBLIC_KEY': base64PublicKey}, runInShell: false, + onStart: any(named: 'onStart'), ), ).called(1); }); @@ -901,16 +1307,21 @@ Reason: Exited with code 70.'''), ); verify( - () => shorebirdProcess.stream('flutter', [ - 'build', - 'macos', - '--release', - '--flavor=flavor', - '--target=target.dart', - '--no-codesign', - '--foo', - 'bar', - ], runInShell: false), + () => shorebirdProcess.stream( + 'flutter', + [ + 'build', + 'macos', + '--release', + '--flavor=flavor', + '--target=target.dart', + '--no-codesign', + '--foo', + 'bar', + ], + runInShell: false, + onStart: any(named: 'onStart'), + ), ).called(1); }); @@ -922,6 +1333,7 @@ Reason: Exited with code 70.'''), any(), any(), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.software.code); }); @@ -952,6 +1364,7 @@ Reason: Exited with code 70.'''), any(), any(), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.success.code); }); @@ -990,6 +1403,7 @@ Reason: Exited with code 70.'''), any(), any(), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.software.code); }); @@ -1015,6 +1429,7 @@ Reason: Exited with code 70.'''), any(), environment: any(named: 'environment'), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async { appDill = File(p.join(projectRoot.path, '.dart_tool', 'app.dill')) @@ -1051,6 +1466,7 @@ Reason: Exited with code 70.'''), ['build', 'ipa', '--release'], environment: any(named: 'environment'), runInShell: false, + onStart: any(named: 'onStart'), ), ).called(1); expect(result.kernelFile.path, equals(appDill.path)); @@ -1071,6 +1487,7 @@ Reason: Exited with code 70.'''), ['build', 'ipa', '--release'], environment: {'SHOREBIRD_PUBLIC_KEY': base64PublicKey}, runInShell: false, + onStart: any(named: 'onStart'), ), ).called(1); }); @@ -1087,19 +1504,68 @@ Reason: Exited with code 70.'''), ); verify( - () => shorebirdProcess.stream('flutter', [ - 'build', - 'ipa', - '--release', - '--flavor=flavor', - '--target=target.dart', - '--no-codesign', - '--foo', - 'bar', - ], runInShell: false), + () => shorebirdProcess.stream( + 'flutter', + [ + 'build', + 'ipa', + '--release', + '--flavor=flavor', + '--target=target.dart', + '--no-codesign', + '--foo', + 'bar', + ], + runInShell: false, + onStart: any(named: 'onStart'), + ), ).called(1); }); + group('when Flutter supports build tracing', () { + setUp(() { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 7)); + }); + + test( + 'passes --trace with a path under build/shorebird/debug', + () async { + await runWithOverrides(() async { + await builder.prepareBuildTrace(platform: 'ios'); + await builder.buildIpa(); + }); + + final expectedTracePath = p.join( + projectRoot.path, + 'build', + 'shorebird', + 'debug', + 'build-trace-ios.json', + ); + verify( + () => shorebirdProcess.stream( + 'flutter', + [ + 'build', + 'ipa', + '--release', + '--shorebird-trace=$expectedTracePath', + ], + environment: any(named: 'environment'), + runInShell: false, + onStart: any(named: 'onStart'), + ), + ).called(1); + expect( + Directory(p.dirname(expectedTracePath)).existsSync(), + isTrue, + ); + }, + ); + }); + group('when the build fails', () { group('with non-zero exit code', () { setUp(() { @@ -1108,6 +1574,7 @@ Reason: Exited with code 70.'''), any(), any(), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.software.code); }); @@ -1138,6 +1605,7 @@ Reason: Exited with code 70.'''), any(), any(), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.success.code); }); @@ -1176,6 +1644,7 @@ Reason: Exited with code 70.'''), any(), any(), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.software.code); }); @@ -1211,6 +1680,7 @@ Reason: Exited with code 70.'''), any(), environment: any(named: 'environment'), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async { appDill = File(p.join(projectRoot.path, '.dart_tool', 'app.dill')) @@ -1223,6 +1693,43 @@ Reason: Exited with code 70.'''), }); }); + test( + 'passes --shorebird-trace when Flutter supports build tracing', + () async { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 7)); + + await runWithOverrides(() async { + await builder.prepareBuildTrace(platform: 'ios'); + await builder.buildIosFramework(); + }); + + final expectedTracePath = p.join( + projectRoot.path, + 'build', + 'shorebird', + 'debug', + 'build-trace-ios.json', + ); + verify( + () => shorebirdProcess.stream( + 'flutter', + [ + 'build', + 'ios-framework', + '--no-debug', + '--no-profile', + '--shorebird-trace=$expectedTracePath', + ], + environment: any(named: 'environment'), + runInShell: false, + onStart: any(named: 'onStart'), + ), + ).called(1); + }, + ); + group('when .dart_tool directory exists', () { late File foo; setUp(() { @@ -1246,6 +1753,7 @@ Reason: Exited with code 70.'''), ['build', 'ios-framework', '--no-debug', '--no-profile'], environment: any(named: 'environment'), runInShell: false, + onStart: any(named: 'onStart'), ), ).called(1); expect(result.kernelFile.path, equals(appDill.path)); @@ -1257,14 +1765,19 @@ Reason: Exited with code 70.'''), ); verify( - () => shorebirdProcess.stream('flutter', [ - 'build', - 'ios-framework', - '--no-debug', - '--no-profile', - '--foo', - 'bar', - ], runInShell: false), + () => shorebirdProcess.stream( + 'flutter', + [ + 'build', + 'ios-framework', + '--no-debug', + '--no-profile', + '--foo', + 'bar', + ], + runInShell: false, + onStart: any(named: 'onStart'), + ), ).called(1); }); @@ -1282,6 +1795,7 @@ Reason: Exited with code 70.'''), ['build', 'ios-framework', '--no-debug', '--no-profile'], environment: {'SHOREBIRD_PUBLIC_KEY': base64PublicKey}, runInShell: false, + onStart: any(named: 'onStart'), ), ).called(1); }); @@ -1300,6 +1814,7 @@ Reason: Exited with code 70.'''), any(), any(), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.success.code); }); @@ -1331,6 +1846,7 @@ Reason: Exited with code 70.'''), any(), any(), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.software.code); }); @@ -1375,14 +1891,19 @@ Reason: Exited with code 70.'''), ); verify( - () => shorebirdProcess.stream('gen_snapshot', [ - '--deterministic', - '--snapshot-kind=app-aot-elf', - '--elf=/path/to/out', - '--foo', - 'bar', - '/app/dill/path', - ], runInShell: false), + () => shorebirdProcess.stream( + 'gen_snapshot', + [ + '--deterministic', + '--snapshot-kind=app-aot-elf', + '--elf=/path/to/out', + '--foo', + 'bar', + '/app/dill/path', + ], + runInShell: false, + onStart: any(named: 'onStart'), + ), ).called(1); }); @@ -1393,6 +1914,7 @@ Reason: Exited with code 70.'''), any(), any(), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.software.code); }); @@ -1445,14 +1967,55 @@ Reason: Exited with code 70.'''), () => artifactManager.getWindowsReleaseDirectory(), ).thenReturn(windowsReleaseDirectory); when( - () => shorebirdProcess.stream('flutter', [ - 'build', - 'windows', - '--release', - ], runInShell: false), + () => shorebirdProcess.stream( + 'flutter', + [ + 'build', + 'windows', + '--release', + ], + runInShell: false, + onStart: any(named: 'onStart'), + ), ).thenAnswer((_) async => ExitCode.success.code); }); + test( + 'passes --shorebird-trace when Flutter supports build tracing', + () async { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 7)); + + await runWithOverrides(() async { + await builder.prepareBuildTrace(platform: 'windows'); + await builder.buildWindowsApp(); + }); + + final expectedTracePath = p.join( + projectRoot.path, + 'build', + 'shorebird', + 'debug', + 'build-trace-windows.json', + ); + verify( + () => shorebirdProcess.stream( + 'flutter', + [ + 'build', + 'windows', + '--release', + '--shorebird-trace=$expectedTracePath', + ], + environment: any(named: 'environment'), + runInShell: false, + onStart: any(named: 'onStart'), + ), + ).called(1); + }, + ); + group('when target is provided', () { test('forwards target to flutter command', () async { await runWithOverrides( @@ -1460,12 +2023,17 @@ Reason: Exited with code 70.'''), ); verify( - () => shorebirdProcess.stream('flutter', [ - 'build', - 'windows', - '--release', - '--target=target.dart', - ], runInShell: false), + () => shorebirdProcess.stream( + 'flutter', + [ + 'build', + 'windows', + '--release', + '--target=target.dart', + ], + runInShell: false, + onStart: any(named: 'onStart'), + ), ).called(1); }); }); @@ -1477,6 +2045,7 @@ Reason: Exited with code 70.'''), any(), any(), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.software.code); }); @@ -1515,6 +2084,7 @@ Reason: Exited with code 70.'''), any(), any(), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.success.code); }); @@ -1541,6 +2111,7 @@ Reason: Exited with code 70.'''), any(), environment: any(named: 'environment'), runInShell: any(named: 'runInShell'), + onStart: any(named: 'onStart'), ), ).thenAnswer((_) async => ExitCode.success.code); }); @@ -1556,10 +2127,87 @@ Reason: Exited with code 70.'''), ['build', 'windows', '--release'], environment: {'SHOREBIRD_PUBLIC_KEY': publicKey}, runInShell: false, + onStart: any(named: 'onStart'), ), ).called(1); }); }); }); + + group('prepareBuildTrace', () { + test( + 'leaves traceFile null when Flutter pin does not support tracing', + () async { + // shorebirdFlutter.resolveFlutterVersion default in setUp is 3.0.0, + // which is below buildTraceSupportConstraint.minVersion, and the + // default flutterRevision stub ('1234') isn't in the allowlist. + await runWithOverrides(() async { + await builder.prepareBuildTrace(platform: 'android'); + expect(buildTraceSession.traceFile, isNull); + expect(buildTraceSession.platform, 'android'); + }); + }, + ); + + test( + 'sets traceFile for an allowlisted revision below the floor', + () async { + // Version is strictly below the min floor, so only the + // allowlist can admit this combination. + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 6)); + when(() => shorebirdEnv.flutterRevision).thenReturn( + buildTraceSupportConstraint.allowedRevisions.first, + ); + + await runWithOverrides(() async { + await builder.prepareBuildTrace(platform: 'android'); + expect(buildTraceSession.traceFile, isNotNull); + }); + }, + ); + + test( + 'treats unresolved Flutter version as new enough (dev pin)', + () async { + // resolveFlutterVersion returns null for revisions not on a + // flutter_release branch (e.g. a pinned dev revision). The + // minVersion fallback admits these. + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => null); + + await runWithOverrides(() async { + await builder.prepareBuildTrace(platform: 'android'); + expect(buildTraceSession.traceFile, isNotNull); + }); + }, + ); + }); + + group('writeBuildTraceSummary', () { + test('logs detail and returns when trace file is missing', () async { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 7)); + + await runWithOverrides(() async { + await builder.prepareBuildTrace(platform: 'android'); + // Simulate a build that never produced a trace file (e.g. an + // older Flutter that accepts --shorebird-trace but silently + // drops the flag, or a build that failed before writing). + expect(buildTraceSession.traceFile!.existsSync(), isFalse); + builder.writeBuildTraceSummary(); + expect(buildTraceSession.summary, isNull); + }); + + verify( + () => logger.detail( + any(that: contains('Skipping build trace summary')), + ), + ).called(1); + }); + }); }); } diff --git a/packages/shorebird_cli/test/src/artifact_builder/build_environment_test.dart b/packages/shorebird_cli/test/src/artifact_builder/build_environment_test.dart new file mode 100644 index 00000000..7d3555f5 --- /dev/null +++ b/packages/shorebird_cli/test/src/artifact_builder/build_environment_test.dart @@ -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 {}, + 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 {}, + 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 {}, + 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 {}, + 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 {}, + 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 {}, + 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 {}, + homeDir: home, + projectRoot: tmp, + ); + expect(env.gradleDevelocityDetected, isTrue); + expect(env.gradleInitScriptCount, 1); + }); + + test('classifies common CI providers', () { + expect( + BuildEnvironment.detect( + environment: const {'GITHUB_ACTIONS': 'true'}, + ).ciProvider, + 'github', + ); + expect( + BuildEnvironment.detect( + environment: const {'CI': 'true'}, + ).ciProvider, + 'other', + ); + expect( + BuildEnvironment.detect( + environment: const {'CIRCLECI': 'true'}, + ).ciProvider, + 'circle', + ); + }); + + test('toJson is privacy-safe (only bool/int/enum-string)', () { + final env = BuildEnvironment.detect( + environment: const {'GITHUB_ACTIONS': 'true'}, + ); + final j = env.toJson(); + void checkLeaf(Object? v) { + expect( + v, + anyOf(isA(), isA(), isA(), 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); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/artifact_builder/build_trace_session_test.dart b/packages/shorebird_cli/test/src/artifact_builder/build_trace_session_test.dart new file mode 100644 index 00000000..c30bbb9b --- /dev/null +++ b/packages/shorebird_cli/test/src/artifact_builder/build_trace_session_test.dart @@ -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()); + 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); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/artifact_builder/build_trace_summary_test.dart b/packages/shorebird_cli/test/src/artifact_builder/build_trace_summary_test.dart new file mode 100644 index 00000000..48df1d96 --- /dev/null +++ b/packages/shorebird_cli/test/src/artifact_builder/build_trace_summary_test.dart @@ -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 _event({ + required String name, + required String cat, + required int ts, + required int dur, + required int tid, + Map? 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>()); + expect(j.containsKey('ios'), isFalse); + final android = j['android']! as Map; + expect(android['gradle'], isA>()); + // 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 _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(['podInstall', 'xcode'])); + }); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/artifact_builder/shorebird_tracer_test.dart b/packages/shorebird_cli/test/src/artifact_builder/shorebird_tracer_test.dart new file mode 100644 index 00000000..8ce04f41 --- /dev/null +++ b/packages/shorebird_cli/test/src/artifact_builder/shorebird_tracer_test.dart @@ -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()); + }); + + test('span records a completed event for a successful body', () async { + final result = await tracer.span( + 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( + name: 'unit-test', + category: 'shorebird', + body: () async => throw StateError('boom'), + ), + throwsA(isA()), + ); + expect(tracer.events, hasLength(1)); + expect(tracer.events.single['name'], 'unit-test'); + }); + + test('span forwards args', () async { + await tracer.span( + 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()); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart index a5a5db60..d980c19e 100644 --- a/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart @@ -9,6 +9,7 @@ import 'package:path/path.dart' as p; import 'package:scoped_deps/scoped_deps.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/build_trace_session.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/cache.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; @@ -140,6 +141,9 @@ void main() { aotToolsRef.overrideWith(() => aotTools), artifactBuilderRef.overrideWith(() => artifactBuilder), artifactManagerRef.overrideWith(() => artifactManager), + buildTraceSessionRef.overrideWith( + () => BuildTraceSession(commandStartedAt: DateTime(2023)), + ), cacheRef.overrideWith(() => cache), codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), loggerRef.overrideWith(() => logger), @@ -167,6 +171,12 @@ void main() { aotTools = MockAotTools(); argResults = MockArgResults(); artifactBuilder = MockArtifactBuilder(); + when( + () => artifactBuilder.prepareBuildTrace( + platform: any(named: 'platform'), + ), + ).thenAnswer((_) async {}); + when(artifactBuilder.writeBuildTraceSummary).thenReturn(null); artifactManager = MockArtifactManager(); cache = MockCache(); codePushClientWrapper = MockCodePushClientWrapper(); diff --git a/packages/shorebird_cli/test/src/commands/release/release_command_test.dart b/packages/shorebird_cli/test/src/commands/release/release_command_test.dart index 0ddbc014..90ed72fd 100644 --- a/packages/shorebird_cli/test/src/commands/release/release_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/release_command_test.dart @@ -6,6 +6,7 @@ import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; import 'package:scoped_deps/scoped_deps.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/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/commands/release/release.dart'; @@ -55,6 +56,7 @@ void main() { ); late ArgResults argResults; + late ArtifactBuilder artifactBuilder; late Cache cache; late CodePushClientWrapper codePushClientWrapper; late Directory shorebirdRoot; @@ -72,6 +74,10 @@ void main() { return runScoped( body, values: { + artifactBuilderRef.overrideWith(() => artifactBuilder), + buildTraceSessionRef.overrideWith( + () => BuildTraceSession(commandStartedAt: DateTime(2023)), + ), cacheRef.overrideWith(() => cache), codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), loggerRef.overrideWith(() => logger), @@ -92,6 +98,13 @@ void main() { setUp(() { argResults = MockArgResults(); + artifactBuilder = MockArtifactBuilder(); + when( + () => artifactBuilder.prepareBuildTrace( + platform: any(named: 'platform'), + ), + ).thenAnswer((_) async {}); + when(artifactBuilder.writeBuildTraceSummary).thenReturn(null); cache = MockCache(); codePushClientWrapper = MockCodePushClientWrapper(); logger = MockShorebirdLogger(); diff --git a/packages/shorebird_cli/test/src/executables/aot_tools_test.dart b/packages/shorebird_cli/test/src/executables/aot_tools_test.dart index 13169f9f..2eca4d0a 100644 --- a/packages/shorebird_cli/test/src/executables/aot_tools_test.dart +++ b/packages/shorebird_cli/test/src/executables/aot_tools_test.dart @@ -5,6 +5,7 @@ import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; import 'package:pub_semver/pub_semver.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/executables/executables.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; @@ -26,10 +27,18 @@ void main() { late File dartBinaryFile; late AotTools aotTools; - R runWithOverrides(R Function() body) { + R runWithOverrides( + R Function() body, { + BuildTraceSession? traceSession, + }) { return runScoped( body, values: { + buildTraceSessionRef.overrideWith( + () => + traceSession ?? + BuildTraceSession(commandStartedAt: DateTime(2023)), + ), cacheRef.overrideWith(() => cache), processRef.overrideWith(() => process), shorebirdArtifactsRef.overrideWith(() => shorebirdArtifacts), @@ -1085,5 +1094,96 @@ Run "aot_tools help " for more information about a command. expect(result, Version(1, 2, 3)); }); }); + + group('build trace wiring', () { + test( + 'prepends --trace= 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>.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; + // Expect: dart run --trace= --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>.empty()); + return mockProcess; + }); + + await runWithOverrides(() => aotTools.getVersion()); + + final captured = + verify( + () => process.start( + dartBinaryFile.path, + captureAny(), + workingDirectory: any(named: 'workingDirectory'), + ), + ).captured.single + as List; + expect(captured.any((a) => a.startsWith('--trace=')), isFalse); + }, + ); + }); }); } diff --git a/packages/shorebird_cli/test/src/http_client/http_client_test.dart b/packages/shorebird_cli/test/src/http_client/http_client_test.dart index 07314dc9..982f6e4c 100644 --- a/packages/shorebird_cli/test/src/http_client/http_client_test.dart +++ b/packages/shorebird_cli/test/src/http_client/http_client_test.dart @@ -1,4 +1,3 @@ -import 'package:http/retry.dart'; import 'package:scoped_deps/scoped_deps.dart'; import 'package:shorebird_cli/src/http_client/http_client.dart'; import 'package:test/test.dart'; @@ -7,7 +6,7 @@ void main() { group('scoped', () { test('creates instance with default constructor', () { final instance = runScoped(() => httpClient, values: {httpClientRef}); - expect(instance, isA()); + expect(instance, isA()); }); }); } diff --git a/packages/shorebird_cli/test/src/http_client/tracing_client_test.dart b/packages/shorebird_cli/test/src/http_client/tracing_client_test.dart new file mode 100644 index 00000000..58dcf142 --- /dev/null +++ b/packages/shorebird_cli/test/src/http_client/tracing_client_test.dart @@ -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 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(); + }); + + 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()), + ); + }); + + 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(); + }); + + expect( + (tracer.events.single['args']! as Map)['contentLength'], + 5, + ); + }, + ); + }); +} diff --git a/packages/shorebird_cli/test/src/shorebird_process_test.dart b/packages/shorebird_cli/test/src/shorebird_process_test.dart index 04c45b69..3b4c2055 100644 --- a/packages/shorebird_cli/test/src/shorebird_process_test.dart +++ b/packages/shorebird_cli/test/src/shorebird_process_test.dart @@ -454,6 +454,20 @@ void main() { ), ).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', () { diff --git a/pubspec.yaml b/pubspec.yaml index eceaceb6..c929d7b4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,6 +10,7 @@ workspace: - packages/jwt - packages/redis_client - packages/scoped_deps + - packages/shorebird_build_trace - packages/shorebird_cli - packages/shorebird_code_push_client - packages/shorebird_code_push_protocol