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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE.txt +21 -0
- data/README.md +193 -0
- data/examples/sample.ndjson +15 -0
- data/exe/rspec-telemetry +7 -0
- data/exe/rspec-telemetry-compare +6 -0
- data/exe/rspec-telemetry-viewer +67 -0
- data/lib/rspec_telemetry/analyzer.rb +170 -0
- data/lib/rspec_telemetry/cli.rb +71 -0
- data/lib/rspec_telemetry/compare_cli.rb +129 -0
- data/lib/rspec_telemetry/config.rb +40 -0
- data/lib/rspec_telemetry/console_report.rb +124 -0
- data/lib/rspec_telemetry/factory_aggregation.rb +50 -0
- data/lib/rspec_telemetry/factory_comparison.rb +101 -0
- data/lib/rspec_telemetry/formatter.rb +91 -0
- data/lib/rspec_telemetry/ndjson.rb +24 -0
- data/lib/rspec_telemetry/recorder.rb +75 -0
- data/lib/rspec_telemetry/subscribers/factory_bot.rb +88 -0
- data/lib/rspec_telemetry/summary.rb +134 -0
- data/lib/rspec_telemetry/trace/viewer/app.rb +269 -0
- data/lib/rspec_telemetry/trace/viewer/app_renderer.rb +88 -0
- data/lib/rspec_telemetry/trace/viewer/detail_lines.rb +75 -0
- data/lib/rspec_telemetry/trace/viewer/detail_pane.rb +28 -0
- data/lib/rspec_telemetry/trace/viewer/document.rb +198 -0
- data/lib/rspec_telemetry/trace/viewer/follow_controller.rb +51 -0
- data/lib/rspec_telemetry/trace/viewer/format.rb +23 -0
- data/lib/rspec_telemetry/trace/viewer/label.rb +84 -0
- data/lib/rspec_telemetry/trace/viewer/layout.rb +100 -0
- data/lib/rspec_telemetry/trace/viewer/pane_resizer.rb +99 -0
- data/lib/rspec_telemetry/trace/viewer/report_pane.rb +26 -0
- data/lib/rspec_telemetry/trace/viewer/report_view.rb +86 -0
- data/lib/rspec_telemetry/trace/viewer/screen/ranked_screen.rb +66 -0
- data/lib/rspec_telemetry/trace/viewer/screen/timeline_screen.rb +180 -0
- data/lib/rspec_telemetry/trace/viewer/source.rb +52 -0
- data/lib/rspec_telemetry/trace/viewer/source_pane.rb +70 -0
- data/lib/rspec_telemetry/trace/viewer/source_resolver.rb +56 -0
- data/lib/rspec_telemetry/trace/viewer/source_view.rb +63 -0
- data/lib/rspec_telemetry/trace/viewer/status_line.rb +50 -0
- data/lib/rspec_telemetry/trace/viewer/text_report.rb +49 -0
- data/lib/rspec_telemetry/trace/viewer/theme.rb +30 -0
- data/lib/rspec_telemetry/trace/viewer/time_bar.rb +46 -0
- data/lib/rspec_telemetry/trace/viewer/timeline_pane.rb +53 -0
- data/lib/rspec_telemetry/trace/viewer/version.rb +9 -0
- data/lib/rspec_telemetry/trace/viewer.rb +31 -0
- data/lib/rspec_telemetry/version.rb +5 -0
- data/lib/rspec_telemetry/writer.rb +59 -0
- data/lib/rspec_telemetry.rb +102 -0
- 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}
|
data/exe/rspec-telemetry
ADDED
|
@@ -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
|