Repository: flutter
Issue: Add simple overall timing information to `TimelineSummary`, because existing metrics do not reflect real performance
Labels: framework,c: performance,d: devtools,has reproducible steps,P3,found in release: 3.7,found in release: 3.9,team-framework,triaged-framework

Hi thanks for the wonderful Flutter framework! When using `TimelineSummary` for my own continuous benchmark infrastructure, I realize it sometimes does not reflect the jank-ness that a real user faces. This issue (and potential PR) partially solves it.

Take the following screenshot (click below to expand) as an example. As can be seen, DoMakeRasterSnapshot (caused by `toImage` of my app) takes ~200ms and thus this frame is super janky. However, TimelineSummary does not report this at all, and only report the build and the raster time, which is both quick.

<details>

![image](https://user-images.githubusercontent.com/5236035/231389310-a8e2a3f1-4e78-4b08-93b4-1887c4bc9f0d.png)

</details>

As a side remark, there are other uncomfortable cases that this "overall" metric does not cover. For example, as mentioned in https://cjycode.com/flutter_smooth/benchmark/analyze/linearity/definition, a "latency decrease" will cause an uncomfortable jump.

I have already implemented it, and below is the code (currently implemented as a new class, but trivial to be merged into the main TimelineSummary class). I will PR if this proposal looks acceptable :)

<details>

```dart
class EnhancedTimelineSummary extends TimelineSummary {
  static const _kTag = 'EnhancedTimelineSummary';

  EnhancedTimelineSummary.summarize(super.timeline) : super.summarize();

  @override
  Map<String, dynamic> get summaryJson => <String, dynamic>{...super.summaryJson, ..._overallDurationsSummaryJson};

  Map<String, dynamic> get _overallDurationsSummaryJson {
    final overallDurations = _extractOverallDurations();
    return <String, dynamic>{
      'average_frame_overall_time_millis': averageInMillis(overallDurations),
      '90th_percentile_frame_overall_time_millis': percentileInMillis(overallDurations, 90.0),
      '99th_percentile_frame_overall_time_millis': percentileInMillis(overallDurations, 99.0),
      'worst_frame_overall_time_millis': maxInMillis(overallDurations),
      'frame_overall_times': overallDurations.map((e) => e.inMicroseconds).toList(),
    };
  }

  List<Duration> _extractOverallDurations() {
    final buildDurationTuples = extractEventPairs(kBuildFrameEventName, _DurationTuple.from);
    final rasterDurationTuples = extractEventPairs(kRasterizeFrameEventName, _DurationTuple.from);
    buildDurationTuples.checkSortedByFirst();
    rasterDurationTuples.checkSortedByFirst();

    final pipelineItemTuples = extractNamedEvents('PipelineItem')
        .groupListsBy((e) => e.json['id'] as String)
        .values
        .map((events) => _DurationTuple.maybeFrom(
            events.firstWhereOrNull((e) => e.phase == 't'), events.firstWhereOrNull((e) => e.phase == 'f')))
        .whereNotNull()
        .sortedBy((tuple) => tuple.first);

    // print('buildDurationTuples=$buildDurationTuples');
    // print('rasterDurationTuples=$rasterDurationTuples');
    // print('pipelineItemTuples=$pipelineItemTuples');

    final ans = <Duration>[];
    for (final pipelineItemTuple in pipelineItemTuples) {
      final buildDurationTuple = buildDurationTuples.findItemThatContains(pipelineItemTuple.first);
      final rasterDurationTuple = rasterDurationTuples.findItemThatContains(pipelineItemTuple.second);
      if (buildDurationTuple == null || rasterDurationTuple == null) {
        Log.i(
            _kTag,
            'extractOverallDurations skip pipelineItemTuple=$pipelineItemTuple since not found build/raster '
            'build=$buildDurationTuple raster=$rasterDurationTuple',
            self: null);
        continue;
      }
      ans.add(rasterDurationTuple.second - buildDurationTuple.first);
    }

    return ans;
  }
}

class _DurationTuple {
  final Duration first, second;

  const _DurationTuple(this.first, this.second);

  factory _DurationTuple.from(TimelineEvent first, TimelineEvent second) =>
      _DurationTuple(Duration(microseconds: first.timestampMicros!), Duration(microseconds: second.timestampMicros!));

  static _DurationTuple? maybeFrom(TimelineEvent? first, TimelineEvent? second) =>
      (first == null || second == null) ? null : _DurationTuple.from(first, second);

  bool contains(Duration d) => first <= d && d <= second;

  @override
  String toString() => 'DurationTuple($first, $second)';
}

extension on List<_DurationTuple> {
  _DurationTuple? findItemThatContains(Duration d) {
    final index = lowerBoundBy(_DurationTuple(d, Duration.zero), (e) => e.first);
    if (index == 0) return null;
    final item = this[index - 1];
    if (!item.contains(d)) return null;
    return item;
  }

  void checkSortedByFirst() {
    if (!isSortedBy((e) => e.first)) throw AssertionError;
  }
}
```

</details>