rspec_telemetry 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +35 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +193 -0
  5. data/examples/sample.ndjson +15 -0
  6. data/exe/rspec-telemetry +7 -0
  7. data/exe/rspec-telemetry-compare +6 -0
  8. data/exe/rspec-telemetry-viewer +67 -0
  9. data/lib/rspec_telemetry/analyzer.rb +170 -0
  10. data/lib/rspec_telemetry/cli.rb +71 -0
  11. data/lib/rspec_telemetry/compare_cli.rb +129 -0
  12. data/lib/rspec_telemetry/config.rb +40 -0
  13. data/lib/rspec_telemetry/console_report.rb +124 -0
  14. data/lib/rspec_telemetry/factory_aggregation.rb +50 -0
  15. data/lib/rspec_telemetry/factory_comparison.rb +101 -0
  16. data/lib/rspec_telemetry/formatter.rb +91 -0
  17. data/lib/rspec_telemetry/ndjson.rb +24 -0
  18. data/lib/rspec_telemetry/recorder.rb +75 -0
  19. data/lib/rspec_telemetry/subscribers/factory_bot.rb +88 -0
  20. data/lib/rspec_telemetry/summary.rb +134 -0
  21. data/lib/rspec_telemetry/trace/viewer/app.rb +269 -0
  22. data/lib/rspec_telemetry/trace/viewer/app_renderer.rb +88 -0
  23. data/lib/rspec_telemetry/trace/viewer/detail_lines.rb +75 -0
  24. data/lib/rspec_telemetry/trace/viewer/detail_pane.rb +28 -0
  25. data/lib/rspec_telemetry/trace/viewer/document.rb +198 -0
  26. data/lib/rspec_telemetry/trace/viewer/follow_controller.rb +51 -0
  27. data/lib/rspec_telemetry/trace/viewer/format.rb +23 -0
  28. data/lib/rspec_telemetry/trace/viewer/label.rb +84 -0
  29. data/lib/rspec_telemetry/trace/viewer/layout.rb +100 -0
  30. data/lib/rspec_telemetry/trace/viewer/pane_resizer.rb +99 -0
  31. data/lib/rspec_telemetry/trace/viewer/report_pane.rb +26 -0
  32. data/lib/rspec_telemetry/trace/viewer/report_view.rb +86 -0
  33. data/lib/rspec_telemetry/trace/viewer/screen/ranked_screen.rb +66 -0
  34. data/lib/rspec_telemetry/trace/viewer/screen/timeline_screen.rb +180 -0
  35. data/lib/rspec_telemetry/trace/viewer/source.rb +52 -0
  36. data/lib/rspec_telemetry/trace/viewer/source_pane.rb +70 -0
  37. data/lib/rspec_telemetry/trace/viewer/source_resolver.rb +56 -0
  38. data/lib/rspec_telemetry/trace/viewer/source_view.rb +63 -0
  39. data/lib/rspec_telemetry/trace/viewer/status_line.rb +50 -0
  40. data/lib/rspec_telemetry/trace/viewer/text_report.rb +49 -0
  41. data/lib/rspec_telemetry/trace/viewer/theme.rb +30 -0
  42. data/lib/rspec_telemetry/trace/viewer/time_bar.rb +46 -0
  43. data/lib/rspec_telemetry/trace/viewer/timeline_pane.rb +53 -0
  44. data/lib/rspec_telemetry/trace/viewer/version.rb +9 -0
  45. data/lib/rspec_telemetry/trace/viewer.rb +31 -0
  46. data/lib/rspec_telemetry/version.rb +5 -0
  47. data/lib/rspec_telemetry/writer.rb +59 -0
  48. data/lib/rspec_telemetry.rb +102 -0
  49. metadata +122 -0
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../ndjson"
4
+
5
+ module RSpecTelemetry
6
+ module Trace
7
+ module Viewer
8
+ # Incrementally folds an NDJSON trace into examples and their child events.
9
+ class Document
10
+ # The single source of truth for which statuses count as failures.
11
+ FAILED_STATUSES = %w[failed error].freeze
12
+
13
+ Action = Data.define(:seq, :verb, :label, :t, :source, :wall_ms, :status, :duration_ms, :exception) do
14
+ def failed? = FAILED_STATUSES.include?(status)
15
+ end
16
+
17
+ Event = Data.define(:seq, :op, :t, :action, :fields)
18
+
19
+ # Hidden from generic labels/details; the original fields remain on each event.
20
+ INFRA_FIELDS = %w[type seq op action wall_ms t timestamp monotonic_time pid thread_id].freeze
21
+
22
+ attr_reader(
23
+ :entries,
24
+ :version,
25
+ :level,
26
+ :metadata,
27
+ :wall_time,
28
+ :failed_action,
29
+ :end_wall_ms,
30
+ :example_count,
31
+ :failure_count,
32
+ :pending_count
33
+ )
34
+
35
+ def self.from_lines(lines)
36
+ lines.each_with_object(new) { |line, doc| doc.apply(line) }
37
+ end
38
+
39
+ def initialize
40
+ @entries = []
41
+ @actions_by_seq = {}
42
+ @action_by_example = {}
43
+ @current_action = nil
44
+ @seq = -1
45
+ @t0 = nil
46
+ @started = false
47
+ @ended = false
48
+ @status = nil
49
+ end
50
+
51
+ # Unknown future event types are kept as generic events instead of dropped.
52
+ def apply(line)
53
+ parsed = parse(line)
54
+ return self unless parsed.is_a?(Hash)
55
+
56
+ @started = true
57
+ set_origin(parsed)
58
+
59
+ case parsed["type"]
60
+ when nil
61
+ self
62
+ when "example.started"
63
+ open_action(parsed)
64
+ when "example.finished"
65
+ close_action(parsed)
66
+ when "factory_bot.run_factory"
67
+ record("factory", parsed)
68
+ when "suite.finished"
69
+ finish(parsed)
70
+ else
71
+ record(parsed["type"], parsed)
72
+ end
73
+
74
+ self
75
+ end
76
+
77
+ def events = @entries.grep(Event)
78
+
79
+ def actions = @entries.grep(Action)
80
+
81
+ def action(seq) = @actions_by_seq[seq]
82
+
83
+ def events_for(action_seq)
84
+ @entries.select { |entry| entry.is_a?(Event) && entry.action == action_seq }
85
+ end
86
+
87
+ def pending? = @started && !@ended
88
+
89
+ def status
90
+ return @status if @status
91
+ return "ok" if @ended
92
+
93
+ "pending"
94
+ end
95
+
96
+ private
97
+
98
+ def parse(line) = Ndjson.parse(line)
99
+
100
+ # Normalize each process-local monotonic clock to t=0 for display.
101
+ def set_origin(parsed)
102
+ @t0 ||= parsed["monotonic_time"]
103
+ end
104
+
105
+ def wall_ms_of(parsed)
106
+ mono = parsed["monotonic_time"]
107
+ return nil if mono.nil? || @t0.nil?
108
+
109
+ ((mono - @t0) * 1000.0).round(3)
110
+ end
111
+
112
+ def next_seq = (@seq += 1)
113
+
114
+ def open_action(parsed)
115
+ wall = wall_ms_of(parsed)
116
+ mark_wall(wall)
117
+ action = Action.new(
118
+ seq: next_seq,
119
+ verb: "example",
120
+ label: parsed["full_description"] || parsed["example_id"] || "(example)",
121
+ t: parsed["t"],
122
+ source: source_of(parsed),
123
+ wall_ms: wall,
124
+ status: nil,
125
+ duration_ms: nil,
126
+ exception: nil
127
+ )
128
+ @entries << action
129
+ @actions_by_seq[action.seq] = action
130
+ @action_by_example[parsed["example_id"]] = action.seq if parsed["example_id"]
131
+ @current_action = action.seq
132
+ end
133
+
134
+ # Data objects are immutable, so finishing an example replaces its entry.
135
+ def close_action(parsed)
136
+ mark_wall(wall_ms_of(parsed))
137
+ seq = @action_by_example[parsed["example_id"]]
138
+ old = seq && @actions_by_seq[seq]
139
+ return if old.nil?
140
+
141
+ updated = old.with(
142
+ status: parsed["status"],
143
+ duration_ms: parsed["duration_ms"],
144
+ exception: exception_of(parsed)
145
+ )
146
+ @actions_by_seq[seq] = updated
147
+ @failed_action = seq if updated.failed?
148
+ index = @entries.index(old)
149
+ @entries[index] = updated if index
150
+ end
151
+
152
+ def record(op, parsed)
153
+ wall = wall_ms_of(parsed)
154
+ mark_wall(wall)
155
+ owner = @action_by_example[parsed["example_id"]] || @current_action
156
+ event = Event.new(
157
+ seq: next_seq,
158
+ op: op,
159
+ t: parsed["t"],
160
+ action: owner,
161
+ fields: parsed.merge("wall_ms" => wall)
162
+ )
163
+ @entries << event
164
+ end
165
+
166
+ def finish(parsed)
167
+ @ended = true
168
+ @example_count = parsed["example_count"]
169
+ @failure_count = parsed["failure_count"]
170
+ @pending_count = parsed["pending_count"]
171
+ @status = parsed["failure_count"].to_i.positive? ? "failed" : "ok"
172
+ mark_wall(wall_ms_of(parsed))
173
+ end
174
+
175
+ def mark_wall(wall)
176
+ return if wall.nil?
177
+
178
+ @end_wall_ms = wall if @end_wall_ms.nil? || wall > @end_wall_ms
179
+ end
180
+
181
+ def source_of(parsed)
182
+ file = parsed["file_path"]
183
+ return nil if file.nil?
184
+
185
+ line = parsed["line_number"]
186
+ line ? "#{file}:#{line}" : file
187
+ end
188
+
189
+ def exception_of(parsed)
190
+ klass = parsed["exception_class"]
191
+ return nil if klass.nil?
192
+
193
+ {"class" => klass, "message" => parsed["exception_message"]}
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpecTelemetry
4
+ module Trace
5
+ module Viewer
6
+ # Owns "follow mode": draining a live source into the Document, keeping the
7
+ # cursor parked at the tail, and advancing the pending spinner.
8
+ class FollowController
9
+ SPINNER = %w[| / - \\].freeze
10
+
11
+ def initialize(source:, active:)
12
+ @source = source
13
+ @active = active
14
+ @stick = active
15
+ @spin = 0
16
+ end
17
+
18
+ def active? = @active
19
+ def wants_tick? = @active
20
+ def spinner = SPINNER[@spin % SPINNER.length]
21
+
22
+ # Flip follow on/off; resume tail-sticking only when parked at the end.
23
+ def toggle(at_end:)
24
+ @active = !@active
25
+ @stick = @active && at_end
26
+ @active
27
+ end
28
+
29
+ # Re-evaluate sticking after the cursor moves (e.g. App#go_to).
30
+ def reset_stick(at_end:)
31
+ @stick = @active && at_end
32
+ end
33
+
34
+ # Advance the spinner and fold any new source lines into the document.
35
+ def drain(document)
36
+ return false unless @active
37
+
38
+ @spin += 1
39
+ lines = @source ? @source.drain : []
40
+ lines.each { |line| document.apply(line) }
41
+ !lines.empty?
42
+ end
43
+
44
+ # Keep the cursor parked at the tail while sticking.
45
+ def stick(list)
46
+ list.to_end if @stick
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpecTelemetry
4
+ module Trace
5
+ module Viewer
6
+ module Format
7
+ def self.ms(value)
8
+ return nil if value.nil?
9
+
10
+ value >= 1000 ? "#{(value / 1000.0).round(2)}s" : "#{value.round}ms"
11
+ end
12
+
13
+ # Inspect a value deterministically across Ruby versions (Ruby 3.4 changed
14
+ # Hash#inspect from `{"k"=>v}` to `{"k" => v}`). Recurses into Hashes.
15
+ def self.value(obj)
16
+ return "{#{obj.map { |key, val| "#{key.inspect} => #{value(val)}" }.join(", ")}}" if obj.is_a?(Hash)
17
+
18
+ obj.inspect
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "document"
4
+ require_relative "format"
5
+
6
+ module RSpecTelemetry
7
+ module Trace
8
+ module Viewer
9
+ module Label
10
+ Segment = Data.define(:text, :style)
11
+
12
+ def self.segments(entry)
13
+ return action_segments(entry) if entry.is_a?(Document::Action)
14
+
15
+ event_segments(entry)
16
+ end
17
+
18
+ def self.plain(entry) = segments(entry).map(&:text).join
19
+
20
+ def self.category(entry)
21
+ if entry.is_a?(Document::Action)
22
+ return entry.failed? ? :error : :action
23
+ end
24
+
25
+ case entry.op
26
+ when "factory"
27
+ entry.fields["depth"].to_i.positive? ? :dim : :plain
28
+ else
29
+ :plain
30
+ end
31
+ end
32
+
33
+ def self.action_segments(action)
34
+ tag = action.failed? ? " [#{action.status.upcase}]" : ""
35
+ style = action.failed? ? :error : :action
36
+ segments = [seg("EXAMPLE #{action.label}#{tag}", style)]
37
+ segments << seg(" at: #{action.source}", :dim) if action.source
38
+ segments
39
+ end
40
+
41
+ def self.event_segments(event)
42
+ case event.op
43
+ when "factory"
44
+ factory_segments(event.fields)
45
+ else
46
+ [seg("#{event.op.upcase} #{compact(extra(event.fields))}", :plain)]
47
+ end
48
+ end
49
+
50
+ def self.factory_segments(fields)
51
+ depth = fields["depth"].to_i
52
+ style = depth.positive? ? :dim : :plain
53
+ [seg("#{" " * depth}FACTORY #{name(fields)}#{traits(fields["traits"])} #{timing(fields)}", style)]
54
+ end
55
+
56
+ def self.name(fields) = "#{fields["factory"]}:#{fields["strategy"]}"
57
+
58
+ def self.timing(fields)
59
+ total = Format.ms(fields["duration_ms"])
60
+ self_ms = fields["self_duration_ms"]
61
+ return total.to_s unless self_ms && self_ms != fields["duration_ms"]
62
+
63
+ "#{total} (self #{Format.ms(self_ms)})"
64
+ end
65
+
66
+ def self.traits(list)
67
+ list.nil? || list.empty? ? "" : " [#{Array(list).join(",")}]"
68
+ end
69
+
70
+ def self.extra(fields)
71
+ fields.reject { |key, _| Document::INFRA_FIELDS.include?(key) }
72
+ end
73
+
74
+ def self.seg(text, style) = Segment.new(text: text, style: style)
75
+
76
+ def self.compact(value)
77
+ return "" if value.nil? || value.empty?
78
+
79
+ Format.value(value)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tui_tui"
4
+
5
+ module RSpecTelemetry
6
+ module Trace
7
+ module Viewer
8
+ # Pure geometry shared by rendering and mouse hit-testing.
9
+ module Layout
10
+ MIN_TWO_PANE = 60
11
+ GUTTER = 1
12
+ MIN_PANE_COLS = 12
13
+ MIN_SOURCE_ROWS = 3
14
+ MIN_BODY_ROWS = 4
15
+
16
+ Regions = Data.define(
17
+ :time,
18
+ :body,
19
+ :list,
20
+ :detail,
21
+ :divider,
22
+ :source,
23
+ :source_top,
24
+ :content,
25
+ :status
26
+ )
27
+
28
+ # Regions are nil when the terminal is too small or a feature is hidden.
29
+ def self.compute(size:, want_time_bar:, want_source:, split_ratio:, source_rows:)
30
+ return single(whole(size)) if size.rows < 2
31
+
32
+ body, status = whole(size).split_h(size.rows - 1)
33
+ time, body = carve_time_bar(body) if want_time_bar
34
+ content, body, source, source_top = carve_source(body, source_rows) if want_source
35
+ list, detail, divider = split_panes(body, split_ratio)
36
+
37
+ Regions.new(
38
+ time: time,
39
+ body: body,
40
+ list: list,
41
+ detail: detail,
42
+ divider: divider,
43
+ source: source,
44
+ source_top: source_top,
45
+ content: content,
46
+ status: status
47
+ )
48
+ end
49
+
50
+ def self.source_height(content, source_rows)
51
+ # Keep both source and main body usable while dragging.
52
+ lo = MIN_SOURCE_ROWS
53
+ hi = content.rows - MIN_BODY_ROWS
54
+ return nil if hi < lo
55
+
56
+ source_rows.clamp(lo, hi)
57
+ end
58
+
59
+ def self.single(body)
60
+ Regions.new(
61
+ time: nil,
62
+ body: body,
63
+ list: body,
64
+ detail: nil,
65
+ divider: nil,
66
+ source: nil,
67
+ source_top: nil,
68
+ content: nil,
69
+ status: nil
70
+ )
71
+ end
72
+
73
+ def self.carve_time_bar(body)
74
+ return [nil, body] if body.rows < 2
75
+
76
+ body.split_h(1)
77
+ end
78
+
79
+ def self.carve_source(body, source_rows)
80
+ return [nil, body, nil, nil] if body.rows < MIN_BODY_ROWS + MIN_SOURCE_ROWS + 1
81
+
82
+ height = source_height(body, source_rows)
83
+ return [nil, body, nil, nil] if height.nil?
84
+
85
+ top, source = body.split_h(body.rows - height)
86
+ [body, top, source, source.row]
87
+ end
88
+
89
+ def self.split_panes(body, ratio)
90
+ return [body, nil, nil] if body.cols < MIN_TWO_PANE
91
+
92
+ left, detail = body.split_ratio(ratio, min: MIN_PANE_COLS, gutter: GUTTER)
93
+ [left, detail, left.col + left.cols]
94
+ end
95
+
96
+ def self.whole(size) = TuiTui::Rect.new(row: 1, col: 1, rows: size.rows, cols: size.cols)
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "layout"
4
+
5
+ module RSpecTelemetry
6
+ module Trace
7
+ module Viewer
8
+ # Owns mouse drag state and reports plain clicks back as focus targets.
9
+ class PaneResizer
10
+ DEFAULT_SOURCE_ROWS = 10
11
+
12
+ attr_reader :split_ratio, :source_rows
13
+
14
+ def initialize(split_ratio: 0.5, source_rows: DEFAULT_SOURCE_ROWS)
15
+ @split_ratio = split_ratio
16
+ @source_rows = source_rows
17
+ @dragging = false
18
+ end
19
+
20
+ def handle(event, region)
21
+ case event.action
22
+ when :press
23
+ return press(event, region)
24
+ when :drag
25
+ drag(event, region)
26
+ when :release
27
+ @dragging = false
28
+ end
29
+
30
+ nil
31
+ end
32
+
33
+ private
34
+
35
+ def press(event, region)
36
+ if on_source_header?(event.row, region)
37
+ @dragging = :source
38
+ nil
39
+ elsif near_divider?(event.col, region)
40
+ @dragging = :divider
41
+ nil
42
+ else
43
+ focus_target(event.col, region)
44
+ end
45
+ end
46
+
47
+ def drag(event, region)
48
+ case @dragging
49
+ when :divider
50
+ drag_divider(event.col, region)
51
+ when :source
52
+ drag_source(event.row, region)
53
+ end
54
+ end
55
+
56
+ def near_divider?(col, region)
57
+ !region.divider.nil? && (col - region.divider).abs <= 1
58
+ end
59
+
60
+ def drag_divider(col, region)
61
+ body = region.body
62
+ return unless region.divider
63
+
64
+ left_cols = (col - body.col).clamp(
65
+ Layout::MIN_PANE_COLS,
66
+ body.cols - Layout::MIN_PANE_COLS - Layout::GUTTER
67
+ )
68
+ @split_ratio = left_cols.to_f / body.cols
69
+ end
70
+
71
+ def on_source_header?(row, region)
72
+ !region.source_top.nil? && row == region.source_top
73
+ end
74
+
75
+ def drag_source(row, region)
76
+ content = region.content
77
+ return unless content
78
+
79
+ bottom = content.row + content.rows - 1
80
+ @source_rows = (bottom - row + 1).clamp(
81
+ Layout::MIN_SOURCE_ROWS,
82
+ content.rows - Layout::MIN_BODY_ROWS
83
+ )
84
+ end
85
+
86
+ def focus_target(col, region)
87
+ return :timeline if in_rect?(region.list, col)
88
+ return :detail if in_rect?(region.detail, col)
89
+
90
+ nil
91
+ end
92
+
93
+ def in_rect?(rect, col)
94
+ !rect.nil? && col >= rect.col && col < rect.col + rect.cols
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "theme"
4
+
5
+ module RSpecTelemetry
6
+ module Trace
7
+ module Viewer
8
+ class ReportPane
9
+ def initialize(rows, list:, focus:)
10
+ @rows = rows
11
+ @list = list
12
+ @focus = focus
13
+ end
14
+
15
+ def draw(canvas, rect)
16
+ highlight = @focus ? Theme::SELECT : Theme::SELECT_BLUR
17
+ TuiTui::List.new(@list).draw(canvas, rect, highlight: highlight, scrollbar: Theme.base) do |index, selected|
18
+ row = @rows[index]
19
+ style = selected ? highlight : Theme.style(row.style)
20
+ TuiTui::Line[TuiTui::Span[row.text, style]]
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "document"
4
+ require_relative "format"
5
+ require_relative "../../factory_aggregation"
6
+
7
+ module RSpecTelemetry
8
+ module Trace
9
+ module Viewer
10
+ module ReportView
11
+ Row = Data.define(:text, :style, :source, :payload)
12
+
13
+ TITLES = {examples: "slowest examples", factories: "factories by self time"}.freeze
14
+
15
+ def self.title(view) = TITLES[view]
16
+
17
+ def self.rows(document, view)
18
+ case view
19
+ when :examples
20
+ example_rows(document)
21
+ when :factories
22
+ factory_rows(document)
23
+ else
24
+ []
25
+ end
26
+ end
27
+
28
+ def self.example_rows(document)
29
+ document.actions.sort_by { |action| -(action.duration_ms || 0.0) }.map do |action|
30
+ tag = action.failed? ? " [#{action.status.upcase}]" : ""
31
+ Row.new(
32
+ text: "#{dur(action.duration_ms)} #{action.label}#{tag}",
33
+ style: action.failed? ? :error : :plain,
34
+ source: action.source,
35
+ payload: action
36
+ )
37
+ end
38
+ end
39
+
40
+ def self.factory_rows(document)
41
+ aggregate(document).top.map do |stat|
42
+ Row.new(
43
+ text: "#{dur(stat.self_total_ms)} #{stat.key} ×#{stat.count} total #{Format.ms(stat.total_ms)}",
44
+ style: :plain,
45
+ source: nil,
46
+ payload: stat
47
+ )
48
+ end
49
+ end
50
+
51
+ def self.detail(payload)
52
+ return [] unless payload.is_a?(FactoryAggregation::Stat)
53
+
54
+ [
55
+ "FACTORY #{payload.key}",
56
+ "",
57
+ "count: #{payload.count}",
58
+ "self total: #{Format.ms(payload.self_total_ms)} (children excluded)",
59
+ "total: #{Format.ms(payload.total_ms)} (children included)",
60
+ "avg: #{Format.ms(payload.avg_ms)}",
61
+ "max: #{Format.ms(payload.max_ms)}"
62
+ ]
63
+ end
64
+
65
+ def self.aggregate(document)
66
+ acc = FactoryAggregation::Accumulator.new
67
+ document.events.each do |event|
68
+ next unless event.op == "factory"
69
+
70
+ fields = event.fields
71
+ acc.add(
72
+ factory: fields["factory"],
73
+ strategy: fields["strategy"],
74
+ duration_ms: fields["duration_ms"],
75
+ self_duration_ms: fields["self_duration_ms"]
76
+ )
77
+ end
78
+
79
+ acc
80
+ end
81
+
82
+ def self.dur(ms) = (Format.ms(ms) || "-").rjust(8)
83
+ end
84
+ end
85
+ end
86
+ end