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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a39ae80facd19b25cfcbb59d34bde9ad51d47cb815d1ea5bfec9b3a344cead99
4
+ data.tar.gz: 031a509323010facb690d5cedbc53eb3e318087cbb69439832426b24673efa98
5
+ SHA512:
6
+ metadata.gz: 8158f6494ea9a691a3c52c236320cfffd6b32986258ab578a87dc708940c800a5f7fde9342187240fb254158d24bed74edf7d39963856c39488ce468f78f898a
7
+ data.tar.gz: 6d962956bde3f0a320aec11562d584380da45c9c6372a589c1e12cfc5d6361965327624cdfbd6eea08cc6333e7688530095cb91964cce9c8204e2ff2dd3ed611
data/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ ## [0.3.0] - 2026-06-25
4
+
5
+ ### Added
6
+ - `rspec-telemetry-compare` to compare FactoryBot usage between two runs.
7
+
8
+ ### Changed
9
+ - Requires Ruby >= 3.2.
10
+ - The interactive viewer now works out of the box; no need to add `tui_tui`
11
+ yourself.
12
+
13
+ ## [0.2.0] - 2026-06-17
14
+
15
+ ### Changed
16
+ - Collection (the RSpec formatter that writes NDJSON) now runs on Ruby >= 3.1.
17
+ - `tui_tui` is no longer a runtime dependency; it is only needed for the
18
+ interactive viewer (Ruby >= 3.2, `gem "tui_tui"`). The viewer degrades with a
19
+ clear message when it is missing.
20
+
21
+ ### Added
22
+ - Viewer renders Unicode box-drawing chrome (frames, dividers, scrollbar) when
23
+ the terminal supports it, falling back to ASCII otherwise.
24
+
25
+ ### Fixed
26
+ - FactoryBot timing is captured again on Rails 6.x / ActiveSupport 6 (a
27
+ Rails 7-only `require` had silently disabled the subscription).
28
+
29
+ ## [0.1.0] - 2026-06-17
30
+
31
+ First public release.
32
+
33
+ [Unreleased]: https://github.com/takahashim/rspec_telemetry/compare/v0.2.0...HEAD
34
+ [0.2.0]: https://github.com/takahashim/rspec_telemetry/compare/v0.1.0...v0.2.0
35
+ [0.1.0]: https://github.com/takahashim/rspec_telemetry/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Masayoshi Takahashi (@takahashim)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # rspec_telemetry
2
+
3
+ `rspec_telemetry` collects telemetry data during RSpec runs and writes it as NDJSON.
4
+ It helps you analyze your test suite.
5
+
6
+ - Duration for each example
7
+ - FactoryBot factory time, including nesting depth and self time
8
+ - Rankings of slow factories and slow examples
9
+ - Links between examples and factory events using `example_id`
10
+
11
+ ## Requirements
12
+
13
+ - Ruby >= 3.2.
14
+ - RSpec.
15
+ - `tui_tui` powers the interactive viewer (`rspec-telemetry-viewer`).
16
+ - activesupport (optional): only needed for FactoryBot tracking, which relies on
17
+ `ActiveSupport::Notifications`. FactoryBot pulls it in, so projects using
18
+ FactoryBot already have it; otherwise factory tracking is skipped automatically.
19
+
20
+ ## Install
21
+
22
+ ```ruby
23
+ # Gemfile
24
+ group :test do
25
+ gem "rspec_telemetry"
26
+ end
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```ruby
32
+ # spec/spec_helper.rb, etc.
33
+ require "rspec_telemetry"
34
+ ```
35
+
36
+ It can be used together with normal human-readable output such as `progress`.
37
+
38
+ After requiring the gem, the following features are enabled:
39
+
40
+ - NDJSON output to `tmp/rspec_telemetry.ndjson`
41
+ - Recording of example start/finish events
42
+ - Recording of FactoryBot factory events
43
+ - Recording of suite finish events
44
+
45
+ If you want to specify the formatter explicitly in `.rspec`:
46
+
47
+ ```text
48
+ --format progress
49
+ --format RSpecTelemetry::Formatter
50
+ ```
51
+
52
+ > To disable automatic formatter registration, set the environment variable
53
+ > `RSPEC_TELEMETRY_NO_AUTOLOAD=1`.
54
+
55
+ ## Configuration
56
+
57
+ ```ruby
58
+ RSpecTelemetry.configure do |config|
59
+ config.enabled = true
60
+ config.output_path = "tmp/rspec_telemetry.ndjson"
61
+ config.capture_examples = true
62
+ config.capture_factory_bot = true
63
+ config.print_summary = false # true: print a summary to stderr at the end
64
+ config.flush_each = false # true: flush after each event, useful for tail -f
65
+ config.slow_factory_threshold_ms = nil
66
+ config.slow_example_threshold_ms = 1000.0
67
+ end
68
+ ```
69
+
70
+ ## Finding what is slow: `rspec-telemetry`
71
+
72
+ After running your tests, you can analyze the generated NDJSON file and see where time was spent.
73
+
74
+ ```bash
75
+ $ bundle exec rspec
76
+ $ bundle exec rspec-telemetry # automatically reads tmp/rspec_telemetry*.ndjson
77
+ ```
78
+
79
+ ```text
80
+ Overview
81
+ --------
82
+ examples: 3 (0 failed, 0 pending)
83
+ suite wall time: 91.6ms
84
+ example time (sum): 89.8ms
85
+ factory self time: 63.8ms (71.1% of example time) <- 70% of test time was spent in factories
86
+
87
+ Slowest files (sum of example time)
88
+ Slowest examples
89
+ Slowest factories (by self time, excludes nested children)
90
+ 1. user:create count 9 self 56.1ms total 56.1ms avg 6.2ms max 6.7ms
91
+ ```
92
+
93
+ Options:
94
+
95
+ ```bash
96
+ rspec-telemetry --top 30 # number of items shown in each section
97
+ rspec-telemetry --example "./spec/x_spec.rb[1:2]" # drill down into one example, including nested factories
98
+ rspec-telemetry tmp/rspec_telemetry.1.ndjson ... # explicitly specify files, useful for parallel test runs
99
+ ```
100
+
101
+ Example drill-down output:
102
+
103
+ ```text
104
+ Example: ./spec/x_spec.rb[2:1]
105
+ status: passed duration: 52.4ms
106
+ FactoryBot calls (indented by nesting depth):
107
+ user:create self 6.7ms / total 6.7ms
108
+ order:create self 2.6ms / total 9.3ms <- order total includes child user time
109
+ factory self total: 27.2ms across 6 calls
110
+ ```
111
+
112
+ ## Comparing factory usage between two runs
113
+
114
+ Use `rspec-telemetry-compare` to compare FactoryBot call counts and cumulative
115
+ factory time between two telemetry files.
116
+
117
+ ```bash
118
+ $ bundle exec rspec-telemetry-compare \
119
+ tmp/rspec_telemetry.before.ndjson \
120
+ tmp/rspec_telemetry.after.ndjson
121
+ ```
122
+
123
+ By default, only root factory events (`depth == 0`) are counted, and their
124
+ inclusive `duration_ms` is compared.
125
+
126
+ With `--all-depths`, every factory event is counted and `self_duration_ms` is
127
+ compared. Self time excludes nested child factories, so it avoids double
128
+ counting while showing the actual number and cost of associated factories.
129
+
130
+ ```bash
131
+ rspec-telemetry-compare --sort count BEFORE AFTER
132
+ rspec-telemetry-compare --sort factory BEFORE AFTER
133
+ rspec-telemetry-compare --all-depths BEFORE AFTER
134
+ ```
135
+
136
+ ## TUI viewer: `rspec-telemetry-viewer`
137
+
138
+ You can view the same NDJSON file in an interactive terminal UI.
139
+
140
+ The viewer uses the built-in TUI implementation and only depends on `io-console`.
141
+
142
+ In a terminal, it shows a timeline, details, and a status bar.
143
+ When output is piped or redirected, it prints a text report instead.
144
+
145
+ ```bash
146
+ $ bundle exec rspec-telemetry-viewer tmp/rspec_telemetry.ndjson # TUI when running in a terminal
147
+ $ bundle exec rspec-telemetry-viewer --follow tmp/rspec_telemetry.ndjson # follow a running test
148
+ $ bundle exec rspec-telemetry-viewer --plain tmp/rspec_telemetry.ndjson # force text report
149
+ ```
150
+
151
+ Use `1`, `2`, and `3` to switch between the three screens:
152
+
153
+ - `1` Timeline: shows examples in execution order, with factory calls grouped under each example
154
+ - `2` Slowest examples: ranks examples by duration
155
+ - `3` Factories by self time: groups factories by `factory:strategy` and ranks them by self time
156
+
157
+ ## Example output: NDJSON
158
+
159
+ ```text
160
+ $ bundle exec rspec
161
+ ```
162
+
163
+ ```json
164
+ {"type":"example.started","example_id":"./spec/models/user_spec.rb[1:1]", ...}
165
+ {"type":"factory_bot.run_factory","example_id":"./spec/models/user_spec.rb[1:1]","factory":"user","strategy":"create","traits":["admin"],"overrides":["email"],"duration_ms":42.381,"self_duration_ms":30.12,"depth":0,"parent_factory":null}
166
+ {"type":"example.finished","example_id":"./spec/models/user_spec.rb[1:1]","status":"passed","duration_ms":71.552}
167
+ ```
168
+
169
+ ## Design notes
170
+
171
+ #### Override values are not recorded
172
+
173
+ For FactoryBot overrides, `rspec_telemetry` records only the attribute names.
174
+ It does not record the actual values, because they may contain personal information, secrets, or other sensitive data.
175
+
176
+ #### Nested factories and double counting
177
+
178
+ FactoryBot associations can create other factories internally.
179
+ This is why `rspec_telemetry` also records `self_duration_ms`.
180
+
181
+ Use self time when you want to find which factory is really slow.
182
+
183
+ #### Parallel test runs
184
+
185
+ When `TEST_ENV_NUMBER` is set, `rspec_telemetry` adds the worker number to the output file name.
186
+ This prevents multiple parallel workers from writing to the same NDJSON file.
187
+
188
+ #### Does not affect test results
189
+
190
+ Telemetry should not change the result of your test suite.
191
+
192
+ If writing telemetry data fails, `rspec_telemetry` ignores the error and prints only a warning.
193
+ It does not change whether examples pass or fail.
@@ -0,0 +1,15 @@
1
+ {"type":"example.started","timestamp":"2026-06-14T15:16:27.832904Z","monotonic_time":1124322.211166,"pid":81586,"thread_id":62402625,"example_id":"./sample_spec.rb[1:1]","file_path":"./sample_spec.rb","line_number":14,"full_description":"User creates an admin"}
2
+ {"type":"factory_bot.run_factory","timestamp":"2026-06-14T15:16:27.838621Z","monotonic_time":1124322.216707,"pid":81586,"thread_id":62402625,"example_id":"./sample_spec.rb[1:1]","factory":"user","strategy":"create","traits":["admin"],"overrides":["email"],"duration_ms":5.055,"self_duration_ms":5.055,"depth":0,"parent_factory":null,"factory_class":"User"}
3
+ {"type":"example.finished","timestamp":"2026-06-14T15:16:27.838679Z","monotonic_time":1124322.216764,"pid":81586,"thread_id":62402625,"example_id":"./sample_spec.rb[1:1]","file_path":"./sample_spec.rb","line_number":14,"full_description":"User creates an admin","status":"passed","duration_ms":5.439,"exception_class":null,"exception_message":null}
4
+ {"type":"example.started","timestamp":"2026-06-14T15:16:27.838770Z","monotonic_time":1124322.216854,"pid":81586,"thread_id":62402625,"example_id":"./sample_spec.rb[1:2]","file_path":"./sample_spec.rb","line_number":15,"full_description":"User creates many"}
5
+ {"type":"factory_bot.run_factory","timestamp":"2026-06-14T15:16:27.844238Z","monotonic_time":1124322.222331,"pid":81586,"thread_id":62402625,"example_id":"./sample_spec.rb[1:2]","factory":"user","strategy":"create","traits":[],"overrides":[],"duration_ms":5.09,"self_duration_ms":5.09,"depth":0,"parent_factory":null,"factory_class":"User"}
6
+ {"type":"factory_bot.run_factory","timestamp":"2026-06-14T15:16:27.849341Z","monotonic_time":1124322.227432,"pid":81586,"thread_id":62402625,"example_id":"./sample_spec.rb[1:2]","factory":"user","strategy":"create","traits":[],"overrides":[],"duration_ms":5.056,"self_duration_ms":5.056,"depth":0,"parent_factory":null,"factory_class":"User"}
7
+ {"type":"factory_bot.run_factory","timestamp":"2026-06-14T15:16:27.854444Z","monotonic_time":1124322.23253,"pid":81586,"thread_id":62402625,"example_id":"./sample_spec.rb[1:2]","factory":"user","strategy":"create","traits":[],"overrides":[],"duration_ms":5.042,"self_duration_ms":5.042,"depth":0,"parent_factory":null,"factory_class":"User"}
8
+ {"type":"factory_bot.run_factory","timestamp":"2026-06-14T15:16:27.859693Z","monotonic_time":1124322.237785,"pid":81586,"thread_id":62402625,"example_id":"./sample_spec.rb[1:2]","factory":"user","strategy":"create","traits":[],"overrides":[],"duration_ms":5.217,"self_duration_ms":5.217,"depth":0,"parent_factory":null,"factory_class":"User"}
9
+ {"type":"factory_bot.run_factory","timestamp":"2026-06-14T15:16:27.864781Z","monotonic_time":1124322.242866,"pid":81586,"thread_id":62402625,"example_id":"./sample_spec.rb[1:2]","factory":"user","strategy":"create","traits":[],"overrides":[],"duration_ms":5.034,"self_duration_ms":5.034,"depth":0,"parent_factory":null,"factory_class":"User"}
10
+ {"type":"example.finished","timestamp":"2026-06-14T15:16:27.877409Z","monotonic_time":1124322.255493,"pid":81586,"thread_id":62402625,"example_id":"./sample_spec.rb[1:2]","file_path":"./sample_spec.rb","line_number":15,"full_description":"User creates many","status":"passed","duration_ms":38.623,"exception_class":null,"exception_message":null}
11
+ {"type":"example.started","timestamp":"2026-06-14T15:16:27.877475Z","monotonic_time":1124322.255559,"pid":81586,"thread_id":62402625,"example_id":"./sample_spec.rb[1:3]","file_path":"./sample_spec.rb","line_number":16,"full_description":"User fails"}
12
+ {"type":"factory_bot.run_factory","timestamp":"2026-06-14T15:16:27.882846Z","monotonic_time":1124322.260935,"pid":81586,"thread_id":62402625,"example_id":"./sample_spec.rb[1:3]","factory":"user","strategy":"create","traits":[],"overrides":[],"duration_ms":5.054,"self_duration_ms":5.054,"depth":1,"parent_factory":"order","factory_class":"User"}
13
+ {"type":"factory_bot.run_factory","timestamp":"2026-06-14T15:16:27.885425Z","monotonic_time":1124322.263514,"pid":81586,"thread_id":62402625,"example_id":"./sample_spec.rb[1:3]","factory":"order","strategy":"create","traits":[],"overrides":[],"duration_ms":7.688,"self_duration_ms":2.634,"depth":0,"parent_factory":null,"factory_class":"Order"}
14
+ {"type":"example.finished","timestamp":"2026-06-14T15:16:27.891811Z","monotonic_time":1124322.269896,"pid":81586,"thread_id":62402625,"example_id":"./sample_spec.rb[1:3]","file_path":"./sample_spec.rb","line_number":16,"full_description":"User fails","status":"failed","duration_ms":14.302,"exception_class":"RSpec::Expectations::ExpectationNotMetError","exception_message":"\nexpected: 2\n got: 1\n\n(compared using ==)\n"}
15
+ {"type":"suite.finished","timestamp":"2026-06-14T15:16:27.892101Z","monotonic_time":1124322.270186,"pid":81586,"thread_id":62402625,"example_id":null,"duration_ms":60.663,"example_count":3,"failure_count":1,"pending_count":0}
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rspec_telemetry/version"
5
+ require "rspec_telemetry/cli"
6
+
7
+ exit RSpecTelemetry::CLI.new(ARGV).run
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rspec_telemetry/compare_cli"
5
+
6
+ exit RSpecTelemetry::CompareCLI.new(ARGV).run
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rspec_telemetry/trace/viewer"
5
+
6
+ # Read an rspec_telemetry NDJSON file and either open the interactive TUI (when
7
+ # stdout is a terminal) or print the decorated text report (when piped/redirected).
8
+ # --plain force the text report even on a terminal
9
+ # --follow / -f tail a running file, folding new lines in live (TTY only)
10
+ # --source-root D resolve spec source paths under D (default: cwd)
11
+ # --color MODE auto (default) | none | 16 | 256 | truecolor
12
+ # --mouse / --no-mouse force mouse reporting on/off (default: on;
13
+ # RSPEC_TELEMETRY_MOUSE=0 also disables it)
14
+ def take_option(argv, *flags)
15
+ flags.each do |flag|
16
+ index = argv.index(flag)
17
+ next unless index
18
+
19
+ argv.delete_at(index)
20
+ return argv.delete_at(index)
21
+ end
22
+ nil
23
+ end
24
+
25
+ plain = ARGV.delete("--plain")
26
+ follow = ARGV.delete("--follow") || ARGV.delete("-f")
27
+ mouse = if ARGV.delete("--no-mouse")
28
+ false
29
+ elsif ARGV.delete("--mouse")
30
+ true
31
+ else
32
+ # On by default; RSPEC_TELEMETRY_MOUSE=0/off/false disables it.
33
+ !%w[0 off false].include?(ENV["RSPEC_TELEMETRY_MOUSE"])
34
+ end
35
+ source_root = take_option(ARGV, "--source-root", "-r") || Dir.pwd
36
+ color = take_option(ARGV, "--color") || "auto"
37
+ depth = TuiTui::ColorDepth.from(color)
38
+ path = ARGV[0]
39
+ if path.nil?
40
+ abort "usage: rspec-telemetry-viewer [--plain] [--follow] [--source-root DIR] " \
41
+ "[--color auto|none|16|256|truecolor] FILE.ndjson"
42
+ end
43
+ abort "no such file: #{path}" unless File.file?(path)
44
+
45
+ if !plain && $stdout.tty?
46
+ base_dir = File.dirname(path) # resolve spec source ancestors relative to the file
47
+ if follow
48
+ # Start empty and let the tailer read the existing content first, so the
49
+ # initial frame is populated and subsequent appends stream in.
50
+ document = RSpecTelemetry::Trace::Viewer::Document.new
51
+ source = RSpecTelemetry::Trace::Viewer::TailSource.new(path)
52
+ source.drain.each { |line| document.apply(line) }
53
+ app = RSpecTelemetry::Trace::Viewer::App.new(document, source: source, follow: true,
54
+ base_dir: base_dir, source_root: source_root, depth: depth)
55
+ else
56
+ document = RSpecTelemetry::Trace::Viewer::Document.from_lines(File.foreach(path))
57
+ app = RSpecTelemetry::Trace::Viewer::App.new(document, base_dir: base_dir,
58
+ source_root: source_root, depth: depth)
59
+ end
60
+ TuiTui::Runtime.new(app).run(depth: depth, mouse: mouse)
61
+ else
62
+ document = RSpecTelemetry::Trace::Viewer::Document.from_lines(File.foreach(path))
63
+ enabled = $stdout.tty? && depth != :none
64
+ report = RSpecTelemetry::Trace::Viewer::TextReport.new(document, depth: depth, enabled: enabled)
65
+ puts report.render
66
+ puts report.summary
67
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ndjson"
4
+ require_relative "factory_aggregation"
5
+
6
+ module RSpecTelemetry
7
+ class Analyzer
8
+ Example = Struct.new(
9
+ :example_id,
10
+ :file_path,
11
+ :line_number,
12
+ :full_description,
13
+ :status,
14
+ :duration_ms,
15
+ :fb_self_total_ms,
16
+ :fb_count,
17
+ keyword_init: true
18
+ )
19
+
20
+ FileStat = Struct.new(:file_path, :example_count, :duration_ms, keyword_init: true)
21
+
22
+ attr_reader(
23
+ :examples,
24
+ :files,
25
+ :example_count,
26
+ :failure_count,
27
+ :pending_count,
28
+ :suite_duration_ms
29
+ )
30
+
31
+ def initialize
32
+ @examples = {}
33
+ @factory_acc = FactoryAggregation::Accumulator.new
34
+ @files = {}
35
+ # Factory events arrive before example.finished, so merge them after loading.
36
+ @example_fb = Hash.new { |h, k| h[k] = [0.0, 0] }
37
+ @example_count = 0
38
+ @failure_count = 0
39
+ @pending_count = 0
40
+ @suite_duration_ms = 0.0
41
+ end
42
+
43
+ def self.load(paths)
44
+ new.tap do |analyzer|
45
+ Array(paths).each do |path|
46
+ File.foreach(path) do |line|
47
+ event = Ndjson.parse(line)
48
+ analyzer.add(event) if event
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ def add(event)
55
+ case event["type"]
56
+ when "example.finished"
57
+ add_example(event)
58
+ when "factory_bot.run_factory"
59
+ add_factory(event)
60
+ when "suite.finished"
61
+ add_suite(event)
62
+ end
63
+ end
64
+
65
+ def total_example_ms
66
+ @examples.values.sum { |e| e.duration_ms.to_f }
67
+ end
68
+
69
+ def factories
70
+ @factory_acc.stats
71
+ end
72
+
73
+ def total_factory_self_ms
74
+ @factory_acc.stats.sum(&:self_total_ms)
75
+ end
76
+
77
+ def factory_time_ratio
78
+ total = total_example_ms
79
+ total.zero? ? 0.0 : total_factory_self_ms / total
80
+ end
81
+
82
+ def slow_examples(limit = 20)
83
+ merged_examples.sort_by { |e| -e.duration_ms.to_f }.first(limit)
84
+ end
85
+
86
+ def merged_examples
87
+ # Mutate the report structs at the read boundary; raw aggregates stay separate.
88
+ @examples.values.map do |ex|
89
+ self_total, count = @example_fb[ex.example_id]
90
+ ex.fb_self_total_ms = self_total
91
+ ex.fb_count = count
92
+ ex
93
+ end
94
+ end
95
+
96
+ def top_factories(limit = 20)
97
+ @factory_acc.top(limit)
98
+ end
99
+
100
+ def slow_files(limit = 20)
101
+ @files.values.sort_by { |f| -f.duration_ms.to_f }.first(limit)
102
+ end
103
+
104
+ def self.events_for_example(paths, example_id)
105
+ events = []
106
+ Array(paths).each do |path|
107
+ File.foreach(path) do |line|
108
+ event = Ndjson.parse(line)
109
+ next unless event && event["example_id"] == example_id
110
+
111
+ events << event
112
+ end
113
+ end
114
+
115
+ events
116
+ end
117
+
118
+ private
119
+
120
+ def add_example(event)
121
+ id = event["example_id"] || event["full_description"]
122
+ ex = (@examples[id] ||= Example.new(
123
+ example_id: id,
124
+ file_path: event["file_path"],
125
+ line_number: event["line_number"],
126
+ full_description: event["full_description"],
127
+ status: event["status"],
128
+ duration_ms: 0.0,
129
+ fb_self_total_ms: 0.0,
130
+ fb_count: 0
131
+ ))
132
+ ex.duration_ms = event["duration_ms"]
133
+ ex.status = event["status"]
134
+ ex.file_path ||= event["file_path"]
135
+ ex.line_number ||= event["line_number"]
136
+ ex.full_description ||= event["full_description"]
137
+
138
+ file = event["file_path"]
139
+ return unless file
140
+
141
+ fs = (@files[file] ||= FileStat.new(file_path: file, example_count: 0, duration_ms: 0.0))
142
+ fs.example_count += 1
143
+ fs.duration_ms += event["duration_ms"].to_f
144
+ end
145
+
146
+ def add_factory(event)
147
+ self_ms = (event["self_duration_ms"] || event["duration_ms"]).to_f
148
+ @factory_acc.add(
149
+ factory: event["factory"],
150
+ strategy: event["strategy"],
151
+ duration_ms: event["duration_ms"],
152
+ self_duration_ms: self_ms
153
+ )
154
+
155
+ id = event["example_id"]
156
+ return unless id
157
+
158
+ acc = @example_fb[id]
159
+ acc[0] += self_ms
160
+ acc[1] += 1
161
+ end
162
+
163
+ def add_suite(event)
164
+ @example_count += event["example_count"].to_i
165
+ @failure_count += event["failure_count"].to_i
166
+ @pending_count += event["pending_count"].to_i
167
+ @suite_duration_ms += event["duration_ms"].to_f
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ require_relative "analyzer"
6
+ require_relative "console_report"
7
+
8
+ module RSpecTelemetry
9
+ class CLI
10
+ DEFAULT_GLOB = "tmp/rspec_telemetry*.ndjson"
11
+
12
+ def initialize(argv, out: $stdout, err: $stderr)
13
+ @argv = argv
14
+ @out = out
15
+ @err = err
16
+ @options = {top: 15, example: nil}
17
+ end
18
+
19
+ def run
20
+ paths = parse!
21
+ if paths.empty?
22
+ @err.puts("No telemetry files found (looked for #{DEFAULT_GLOB}).")
23
+ @err.puts("Run `bundle exec rspec` first, or pass file paths explicitly.")
24
+ return 1
25
+ end
26
+
27
+ @options[:example] ? drill_down(paths, @options[:example]) : report(paths)
28
+ 0
29
+ rescue Errno::ENOENT => e
30
+ @err.puts("File not found: #{e.message}")
31
+ 1
32
+ end
33
+
34
+ private
35
+
36
+ def parse!
37
+ parser = OptionParser.new do |o|
38
+ o.banner = "Usage: rspec-telemetry [options] [files...]"
39
+ o.on("-n", "--top N", Integer, "Show top N rows per section (default: 15)") { |v| @options[:top] = v }
40
+ o.on("-e", "--example ID", "Drill down into a single example by id") { |v| @options[:example] = v }
41
+ o.on("-h", "--help", "Show this help") do
42
+ @out.puts(o)
43
+ exit(0)
44
+ end
45
+
46
+ o.on("-v", "--version", "Show version") do
47
+ @out.puts(RSpecTelemetry::VERSION)
48
+ exit(0)
49
+ end
50
+ end
51
+
52
+ files = parser.parse(@argv)
53
+ files.empty? ? Dir.glob(DEFAULT_GLOB).sort : files
54
+ end
55
+
56
+ def report(paths)
57
+ analyzer = Analyzer.load(paths)
58
+ @out.puts(ConsoleReport.new(analyzer, files_count: paths.size, top: @options[:top]).render)
59
+ end
60
+
61
+ def drill_down(paths, example_id)
62
+ events = Analyzer.events_for_example(paths, example_id)
63
+ if events.empty?
64
+ @err.puts("No events found for example: #{example_id}")
65
+ return
66
+ end
67
+
68
+ @out.puts(ConsoleReport.drill_down(events, example_id))
69
+ end
70
+ end
71
+ end