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