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,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ # isolated_execution_state only exists on Rails 7+; ActiveSupport::Notifications
4
+ # works without it, so make it optional to support Rails 6.x (and earlier).
5
+ begin
6
+ require "active_support/isolated_execution_state"
7
+ rescue LoadError
8
+ nil
9
+ end
10
+ require "active_support/notifications"
11
+
12
+ module RSpecTelemetry
13
+ module Subscribers
14
+ class FactoryBot
15
+ STACK_KEY = :rspec_telemetry_fb_stack
16
+
17
+ def initialize(recorder)
18
+ @recorder = recorder
19
+ @subscription = nil
20
+ end
21
+
22
+ def subscribe
23
+ @subscription = ActiveSupport::Notifications.subscribe("factory_bot.run_factory", self)
24
+ end
25
+
26
+ def unsubscribe
27
+ ActiveSupport::Notifications.unsubscribe(@subscription) if @subscription
28
+ @subscription = nil
29
+ end
30
+
31
+ def start(_name, _id, payload)
32
+ RSpecTelemetry.safely("factory_bot#start") do
33
+ stack.push(
34
+ name: payload[:name].to_s,
35
+ monotonic: Process.clock_gettime(Process::CLOCK_MONOTONIC),
36
+ child_ms: 0.0
37
+ )
38
+ end
39
+ end
40
+
41
+ def finish(_name, _id, payload)
42
+ RSpecTelemetry.safely("factory_bot#finish") do
43
+ frame = stack.pop
44
+ next unless frame
45
+ next unless @recorder.config.enabled && @recorder.config.capture_factory_bot
46
+
47
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
+ total = ((now - frame[:monotonic]) * 1000.0).round(3)
49
+ self_ms = (total - frame[:child_ms]).round(3)
50
+
51
+ parent = stack.last
52
+ parent[:child_ms] += total if parent
53
+
54
+ @recorder.record(
55
+ "factory_bot.run_factory",
56
+ factory: payload[:name].to_s,
57
+ strategy: payload[:strategy].to_s,
58
+ traits: Array(payload[:traits]).map(&:to_s),
59
+ overrides: override_names(payload[:overrides]),
60
+ duration_ms: total,
61
+ self_duration_ms: self_ms,
62
+ depth: stack.size,
63
+ parent_factory: parent && parent[:name],
64
+ factory_class: build_class_name(payload[:factory])
65
+ )
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def override_names(overrides)
72
+ return [] unless overrides.is_a?(Hash)
73
+
74
+ overrides.keys.map(&:to_s)
75
+ end
76
+
77
+ def build_class_name(factory)
78
+ factory.build_class.to_s
79
+ rescue StandardError
80
+ nil
81
+ end
82
+
83
+ def stack
84
+ Thread.current[STACK_KEY] ||= []
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "factory_aggregation"
4
+
5
+ module RSpecTelemetry
6
+ class Summary
7
+ ExampleStat = Struct.new(
8
+ :example_id,
9
+ :file_path,
10
+ :line_number,
11
+ :duration_ms,
12
+ :factory_bot_total_ms,
13
+ :factory_bot_count
14
+ )
15
+
16
+ def initialize(config)
17
+ @config = config
18
+ @factory_acc = FactoryAggregation::Accumulator.new
19
+ @examples = {}
20
+ end
21
+
22
+ def add(event)
23
+ case event[:type]
24
+ when "factory_bot.run_factory"
25
+ add_factory(event)
26
+ when "example.finished"
27
+ add_example(event)
28
+ end
29
+ end
30
+
31
+ def factories
32
+ @factory_acc.stats
33
+ end
34
+
35
+ def examples
36
+ @examples.values
37
+ end
38
+
39
+ def top_factories(limit = @config.summary_limit)
40
+ @factory_acc.top(limit)
41
+ end
42
+
43
+ def slow_examples(limit = @config.summary_limit)
44
+ sorted = examples.sort_by { |e| -e.duration_ms.to_f }
45
+ threshold = @config.slow_example_threshold_ms
46
+ sorted = sorted.select { |e| e.duration_ms.to_f >= threshold } if threshold
47
+ sorted.first(limit)
48
+ end
49
+
50
+ private
51
+
52
+ def add_factory(event)
53
+ self_ms = (event[:self_duration_ms] || event[:duration_ms]).to_f
54
+ @factory_acc.add(
55
+ factory: event[:factory],
56
+ strategy: event[:strategy],
57
+ duration_ms: event[:duration_ms],
58
+ self_duration_ms: self_ms
59
+ )
60
+
61
+ example_id = event[:example_id]
62
+ return unless example_id
63
+
64
+ ex = (@examples[example_id] ||= ExampleStat.new(example_id, nil, nil, nil, 0.0, 0))
65
+ ex.factory_bot_total_ms += self_ms
66
+ ex.factory_bot_count += 1
67
+ end
68
+
69
+ def add_example(event)
70
+ example_id = event[:example_id]
71
+ return unless example_id
72
+
73
+ ex = (@examples[example_id] ||= ExampleStat.new(example_id, nil, nil, nil, 0.0, 0))
74
+ ex.file_path = event[:file_path]
75
+ ex.line_number = event[:line_number]
76
+ ex.duration_ms = event[:duration_ms]
77
+ end
78
+ end
79
+
80
+ module SummaryPrinter
81
+ module_function
82
+
83
+ def print(summary, config, io = config.summary_io)
84
+ return unless config.print_summary
85
+
86
+ lines = []
87
+ lines.concat(factory_section(summary, config))
88
+ lines.concat(example_section(summary, config))
89
+ return if lines.empty?
90
+
91
+ io.puts
92
+ io.puts(lines.join("\n"))
93
+ rescue => e
94
+ warn("[rspec-telemetry] failed to print summary: #{e.class}: #{e.message}")
95
+ end
96
+
97
+ def factory_section(summary, _config)
98
+ tops = summary.top_factories
99
+ return [] if tops.empty?
100
+
101
+ lines = ["FactoryBot telemetry summary", "", "Top factories by self time (子factoryを除く):", ""]
102
+ tops.each_with_index do |f, i|
103
+ lines << "#{i + 1}. #{f.key}"
104
+ lines << " count: #{f.count}"
105
+ lines << " self_total: #{round(f.self_total_ms)}ms"
106
+ lines << " total: #{round(f.total_ms)}ms"
107
+ lines << " avg: #{round(f.avg_ms)}ms"
108
+ lines << " max: #{round(f.max_ms)}ms"
109
+ lines << ""
110
+ end
111
+
112
+ lines
113
+ end
114
+
115
+ def example_section(summary, _config)
116
+ slow = summary.slow_examples
117
+ return [] if slow.empty?
118
+
119
+ lines = ["Slow examples:", ""]
120
+ slow.each_with_index do |e, i|
121
+ lines << "#{i + 1}. #{e.example_id}"
122
+ lines << " duration: #{round(e.duration_ms)}ms"
123
+ lines << " factory_bot_total: #{round(e.factory_bot_total_ms)}ms (#{e.factory_bot_count} calls)"
124
+ lines << ""
125
+ end
126
+
127
+ lines
128
+ end
129
+
130
+ def round(value)
131
+ value.to_f.round(1)
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tui_tui"
4
+
5
+ require_relative "document"
6
+ require_relative "theme"
7
+ require_relative "layout"
8
+ require_relative "pane_resizer"
9
+ require_relative "source_view"
10
+ require_relative "app_renderer"
11
+ require_relative "follow_controller"
12
+ require_relative "screen/timeline_screen"
13
+ require_relative "screen/ranked_screen"
14
+
15
+ module RSpecTelemetry
16
+ module Trace
17
+ module Viewer
18
+ # Hosts shared TUI state; per-screen row behavior lives in Screen strategies.
19
+ class App
20
+ WHEEL_ROWS = 3
21
+ REDRAW = "\f"
22
+
23
+ HELP = [
24
+ ["1 / 2 / 3", "timeline / slowest examples / factories"],
25
+ ["j / k ↑ / ↓", "move"],
26
+ ["Space / b", "page down / up"],
27
+ ["g / G", "top / bottom"],
28
+ ["n / N", "next / prev failure (timeline)"],
29
+ ["Tab", "switch pane"],
30
+ ["J / K", "scroll detail"],
31
+ ["Enter", "fold / unfold this example"],
32
+ ["z", "collapse to examples / expand"],
33
+ ["f", "toggle follow"],
34
+ ["/", "filter timeline"],
35
+ ["a", "jump to example"],
36
+ ["s", "toggle source pane"],
37
+ ["S", "view full source"],
38
+ ["mouse", "drag the divider / source header; wheel scrolls; click to focus"],
39
+ ["Ctrl-L", "redraw the screen"],
40
+ ["?", "this help"],
41
+ ["q", "quit"]
42
+ ].freeze
43
+
44
+ attr_reader :detail_scroll
45
+
46
+ def focus = @focus_ring.current
47
+ def follow = @follow_ctl.active?
48
+
49
+ def initialize(
50
+ document,
51
+ depth: TuiTui::ColorDepth.detect,
52
+ source: nil,
53
+ follow: false,
54
+ base_dir: nil,
55
+ source_root: Dir.pwd
56
+ )
57
+ @document = document
58
+ @follow_ctl = FollowController.new(source: source, active: follow)
59
+ @source_view = SourceView.new(source_root: source_root, base_dir: base_dir)
60
+ @renderer = AppRenderer.new
61
+ @list = TuiTui::ScrollList.new
62
+ @detail_scroll = 0
63
+ @focus_ring = TuiTui::FocusRing.new(:timeline, :detail)
64
+ @modal = nil
65
+ @on_result = nil
66
+ @quit_armed = false
67
+ @resizer = PaneResizer.new
68
+ @size = nil
69
+ @source_visible = true
70
+ @view = :timeline
71
+ @timeline_screen = Screen::TimelineScreen.new(@document, @list)
72
+ @screen = @timeline_screen
73
+ end
74
+
75
+ def cursor = @list.cursor
76
+
77
+ def wants_tick? = @follow_ctl.wants_tick?
78
+
79
+ def redraw?(event) = event.is_a?(TuiTui::KeyEvent) && event.key == REDRAW
80
+
81
+ def update(event)
82
+ case event
83
+ when TuiTui::KeyEvent
84
+ @modal ? route_modal(event) : handle_key_event(event)
85
+ when TuiTui::MouseEvent
86
+ @modal ? route_modal(event) : handle_mouse(event)
87
+ when TuiTui::ResizeEvent
88
+ (@size = event.size) && self
89
+ when TuiTui::TickEvent
90
+ poll
91
+ else
92
+ self
93
+ end
94
+ end
95
+
96
+ def view(ctx)
97
+ size = ctx.size
98
+ chrome = ctx.chrome
99
+ @size = size
100
+ r = layout(size)
101
+ result = @renderer.render(render_state(size, r, chrome))
102
+ @detail_scroll = result.detail_scroll
103
+ result.canvas
104
+ end
105
+
106
+ def layout(size)
107
+ # Draw and hit-testing both use this geometry to keep mouse handles aligned.
108
+ Layout.compute(
109
+ size: size,
110
+ want_time_bar: @screen.time_bar? && !@document.end_wall_ms.nil?,
111
+ want_source: @screen.source? && @source_visible,
112
+ split_ratio: @resizer.split_ratio,
113
+ source_rows: @resizer.source_rows
114
+ )
115
+ end
116
+
117
+ def open_modal(widget, &on_result)
118
+ @modal = widget
119
+ @on_result = on_result
120
+ end
121
+
122
+ def go_to(index)
123
+ @list.go_to(index)
124
+ @detail_scroll = 0
125
+ # Follow resumes only when the cursor is still parked at the tail.
126
+ @follow_ctl.reset_stick(at_end: @list.at_end?)
127
+ end
128
+
129
+ private
130
+
131
+ def route_modal(event)
132
+ # Route both keys and clicks so modal widgets (e.g. Confirm buttons)
133
+ # stay interactive with the mouse instead of being ignored.
134
+ result = event.is_a?(TuiTui::MouseEvent) ? @modal.handle_mouse(event) : @modal.handle(event.key)
135
+ return self if result.nil?
136
+
137
+ @modal = nil
138
+ @on_result.call(result) == :quit ? :quit : self
139
+ end
140
+
141
+ def confirm_quit
142
+ open_modal(TuiTui::Confirm.new("Quit the trace viewer?", theme: Theme.base)) { |r| :quit if r == :ok }
143
+ end
144
+
145
+ def handle_key_event(event)
146
+ armed = @quit_armed
147
+ @quit_armed = false
148
+
149
+ case event.key
150
+ when TuiTui::KeyCode::CTRL_C
151
+ # A second consecutive Ctrl-C exits without opening the quit modal.
152
+ return :quit if armed
153
+
154
+ @quit_armed = true
155
+ when "q"
156
+ confirm_quit
157
+ when "?"
158
+ open_modal(TuiTui::Help.new("Keys", HELP, theme: Theme.base)) { nil }
159
+ when "1"
160
+ set_view(:timeline)
161
+ when "2"
162
+ set_view(:examples)
163
+ when "3"
164
+ set_view(:factories)
165
+ when "s"
166
+ @source_visible = !@source_visible
167
+ when "S"
168
+ view_source
169
+ when "j", :down
170
+ move(1)
171
+ when "k", :up
172
+ move(-1)
173
+ when " ", :pgdn
174
+ move(page_rows)
175
+ when "b", :pgup
176
+ move(-page_rows)
177
+ when "g", :home
178
+ go_to(0)
179
+ when "G", :end
180
+ go_to(@list.last)
181
+ when "\t", :backtab
182
+ toggle_focus
183
+ when "f"
184
+ toggle_follow
185
+ when "J"
186
+ @detail_scroll += 1
187
+ when "K"
188
+ @detail_scroll = [@detail_scroll - 1, 0].max
189
+ else
190
+ @screen.handle_key_event(event, self)
191
+ end
192
+
193
+ self
194
+ end
195
+
196
+ def set_view(view)
197
+ return if view == @view
198
+
199
+ @view = view
200
+ @screen = view == :timeline ? @timeline_screen : Screen::RankedScreen.new(@document, @list, view)
201
+ @screen.activate
202
+ go_to(0)
203
+ end
204
+
205
+ def move(delta) = go_to(@list.cursor + delta)
206
+
207
+ def page_rows = @size ? [layout(@size).list.rows, 1].max : 1
208
+
209
+ def toggle_focus
210
+ @focus_ring = @focus_ring.next
211
+ end
212
+
213
+ def handle_mouse(event)
214
+ if event.action == :wheel
215
+ move(event.button == :wheel_up ? -WHEEL_ROWS : WHEEL_ROWS)
216
+ elsif @size
217
+ target = @resizer.handle(event, layout(@size))
218
+ @focus_ring = @focus_ring.focus(target) if target
219
+ end
220
+
221
+ self
222
+ end
223
+
224
+ def toggle_follow
225
+ @follow_ctl.toggle(at_end: @list.at_end?)
226
+ end
227
+
228
+ def poll
229
+ # Order matters: refresh the row count after ingesting, then stick the
230
+ # cursor to the (new) tail.
231
+ @screen.refresh if @follow_ctl.drain(@document)
232
+ @follow_ctl.stick(@list)
233
+ self
234
+ end
235
+
236
+ def view_source
237
+ pager = @source_view.pager(@screen.current_source)
238
+ open_modal(pager) { nil } if pager
239
+ end
240
+
241
+ def position
242
+ count = @screen.count
243
+ return "0/0" if count.zero?
244
+
245
+ "#{@list.cursor + 1}/#{count}"
246
+ end
247
+
248
+ def render_state(size, regions, chrome)
249
+ AppRenderer::State.new(
250
+ size: size,
251
+ chrome: chrome,
252
+ regions: regions,
253
+ document: @document,
254
+ screen: @screen,
255
+ list: @list,
256
+ focus_ring: @focus_ring,
257
+ source_view: @source_view,
258
+ modal: @modal,
259
+ detail_scroll: @detail_scroll,
260
+ quit_armed: @quit_armed,
261
+ follow: @follow_ctl.active?,
262
+ spinner: @follow_ctl.spinner,
263
+ position: position
264
+ )
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tui_tui"
4
+
5
+ require_relative "detail_pane"
6
+ require_relative "status_line"
7
+ require_relative "time_bar"
8
+
9
+ module RSpecTelemetry
10
+ module Trace
11
+ module Viewer
12
+ class AppRenderer
13
+ DIVIDER = TuiTui::Style.new(attrs: [:dim])
14
+
15
+ State = Data.define(
16
+ :size,
17
+ :chrome,
18
+ :regions,
19
+ :document,
20
+ :screen,
21
+ :list,
22
+ :focus_ring,
23
+ :source_view,
24
+ :modal,
25
+ :detail_scroll,
26
+ :quit_armed,
27
+ :follow,
28
+ :spinner,
29
+ :position
30
+ ) do
31
+ def focused?(pane) = focus_ring.focused?(pane)
32
+ end
33
+
34
+ Result = Data.define(:canvas, :detail_scroll)
35
+
36
+ def render(state)
37
+ canvas = TuiTui::Canvas.blank(state.size, chrome: state.chrome)
38
+ state.list.ensure_visible(state.regions.list.rows)
39
+
40
+ TimeBar.new(current_ms: state.screen.time_bar_current, total_ms: state.document.end_wall_ms)
41
+ .draw(canvas, state.regions.time) if state.regions.time
42
+ state.screen.draw_list(canvas, state.regions.list, focused: state.focused?(:timeline))
43
+ detail_scroll = draw_detail(canvas, state)
44
+ draw_divider(canvas, state.regions) if state.regions.divider
45
+ state.source_view.draw(canvas, state.regions.source, state.screen.current_source) if state.regions.source
46
+ draw_status(canvas, state) if state.regions.status
47
+ state.modal&.draw(canvas, state.size)
48
+
49
+ Result.new(canvas: canvas, detail_scroll: detail_scroll || state.detail_scroll)
50
+ end
51
+
52
+ private
53
+
54
+ def draw_detail(canvas, state)
55
+ return nil unless state.regions.detail
56
+
57
+ width = state.regions.detail.split_gutter.first.cols
58
+ lines = state.screen.detail_lines(width)
59
+ scroll = state.detail_scroll.clamp(0, [lines.length - 1, 0].max)
60
+ DetailPane.new(lines, scroll: scroll).draw(canvas, state.regions.detail)
61
+ scroll
62
+ end
63
+
64
+ def draw_divider(canvas, regions)
65
+ body = regions.body
66
+ rule = canvas.chrome.v
67
+ body.rows.times { |dr| canvas.text(body.row + dr, regions.divider, rule, DIVIDER) }
68
+ end
69
+
70
+ def draw_status(canvas, state)
71
+ notice = "Ctrl-C again to quit" if state.quit_armed
72
+ StatusLine
73
+ .new(
74
+ state.document,
75
+ position: state.position,
76
+ notice: notice,
77
+ total_ms: state.document.end_wall_ms,
78
+ follow: state.follow,
79
+ spinner: state.spinner,
80
+ pending: state.document.pending?,
81
+ mode: state.screen.title
82
+ )
83
+ .draw(canvas, state.regions.status)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "document"
4
+ require_relative "label"
5
+ require_relative "format"
6
+ require "tui_tui"
7
+
8
+ module RSpecTelemetry
9
+ module Trace
10
+ module Viewer
11
+ module DetailLines
12
+ def self.for(entry, children: [], duration: nil, width: nil)
13
+ return [] if entry.nil?
14
+
15
+ lines = if entry.is_a?(Document::Action)
16
+ example_lines(entry, children, duration)
17
+ else
18
+ event_lines(entry)
19
+ end
20
+
21
+ return lines if width.nil?
22
+
23
+ lines.flat_map { |line| TuiTui::DisplayText.new(line).wrap(width, indent: " ").map(&:to_s) }
24
+ end
25
+
26
+ def self.example_lines(action, children, duration)
27
+ lines = ["EXAMPLE", "desc: #{action.label}"]
28
+ lines << "at: #{action.source}" if action.source
29
+ lines << "status: #{action.status}" if action.status
30
+ took = action.duration_ms || duration
31
+ lines << "took: #{Format.ms(took)}" if took
32
+ lines.concat(exception_lines(action.exception))
33
+ lines.concat(children_lines(children))
34
+ lines
35
+ end
36
+
37
+ def self.exception_lines(exception)
38
+ return [] if exception.nil?
39
+
40
+ ["", "exception: #{exception["class"]}", " #{exception["message"]}"]
41
+ end
42
+
43
+ def self.children_lines(children)
44
+ return [] if children.empty?
45
+
46
+ total = children.sum { |e| (e.fields["self_duration_ms"] || e.fields["duration_ms"]).to_f }
47
+ header = "ran #{children.size} factor#{children.size == 1 ? "y" : "ies"} (self #{Format.ms(total)}):"
48
+ [""] + [header] + children.map { |event| " #{Label.plain(event)}" }
49
+ end
50
+
51
+ def self.event_lines(event)
52
+ lines = [Label.plain(event), ""]
53
+ event.fields.each do |key, value|
54
+ next if Document::INFRA_FIELDS.include?(key)
55
+
56
+ lines.concat(field_lines(key, value))
57
+ end
58
+
59
+ lines
60
+ end
61
+
62
+ def self.field_lines(key, value)
63
+ case value
64
+ when Hash
65
+ ["#{key}:"] + value.map { |k, v| " #{k}: #{Format.value(v)}" }
66
+ when Array
67
+ ["#{key}:"] + value.map { |v| " - #{Format.value(v)}" }
68
+ else
69
+ ["#{key}: #{Format.value(value)}"]
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "theme"
4
+
5
+ module RSpecTelemetry
6
+ module Trace
7
+ module Viewer
8
+ class DetailPane
9
+ def initialize(lines, scroll:)
10
+ @lines = lines
11
+ @scroll = scroll
12
+ end
13
+
14
+ def length = @lines.length
15
+
16
+ def draw(canvas, rect)
17
+ TuiTui::TextView.draw(canvas, rect, top: @scroll, scrollbar: Theme.base, total: @lines.length) do |index|
18
+ line = @lines[index]
19
+ next nil if line.nil?
20
+
21
+ style = index.zero? ? Theme.style(:action) : Theme.style(:dim)
22
+ TuiTui::Line[TuiTui::Span[line, style]]
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end