rspec_telemetry 0.5.0 → 0.6.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 +4 -4
- data/CHANGELOG.md +19 -1
- data/README.md +27 -1
- data/lib/rspec_telemetry/analyzer.rb +78 -1
- data/lib/rspec_telemetry/config.rb +8 -0
- data/lib/rspec_telemetry/console_report.rb +105 -3
- data/lib/rspec_telemetry/formatter.rb +15 -3
- data/lib/rspec_telemetry/gc_sample.rb +34 -0
- data/lib/rspec_telemetry/subscribers/action_controller.rb +73 -0
- data/lib/rspec_telemetry/subscribers/sql.rb +147 -0
- data/lib/rspec_telemetry/version.rb +1 -1
- data/lib/rspec_telemetry.rb +24 -15
- metadata +8 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cbedbd6b59f7770b4994ee3f8cc9d475f27b606b391e0a63ee8bae67c5ca9098
|
|
4
|
+
data.tar.gz: e6119f45762763a2b3aa2051085bf2b1d6933c664e186b2a55d45622dd0b59ff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6e21234960ca8f439c492f38c4fdf8ea8af5b25b5ca4afdb4722e4f6b15eb9a3a23c3fd5a244ca638f7d3840fa3940301166de51a4158b8b89201083441197e0
|
|
7
|
+
data.tar.gz: 3c0d0a3b778ea07ecb15948b8a8aaa30ff16f187bc5a78bf2d0e3c3d704f022ce8f48ec312012dbc2d5c3b05889a5c1ea56fa6d63df40ea75a5dad45e687aa00
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.6.0] - 2026-07-19
|
|
6
|
+
|
|
7
|
+
Example time is now broken down beyond factories, so you can tell what is
|
|
8
|
+
slow when factories are not the answer.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Rails request timing, with its database and view time.
|
|
12
|
+
- SQL time, split into queries fired by factories and queries fired by the
|
|
13
|
+
code under test. Set `slow_sql_threshold_ms` to also record the individual
|
|
14
|
+
queries above a threshold.
|
|
15
|
+
- GC time and allocation counts per example.
|
|
16
|
+
- Config flags for the above: `capture_requests`, `capture_sql`,
|
|
17
|
+
`capture_gc` (all default to `true`).
|
|
18
|
+
- `rspec-telemetry` reports the new numbers in its overview, adds a
|
|
19
|
+
"Slowest queries" section, and shows requests and SQL in the `--example`
|
|
20
|
+
drill-down.
|
|
21
|
+
|
|
5
22
|
## [0.5.0] - 2026-07-16
|
|
6
23
|
|
|
7
24
|
### Added
|
|
@@ -57,7 +74,8 @@
|
|
|
57
74
|
|
|
58
75
|
First public release.
|
|
59
76
|
|
|
60
|
-
[Unreleased]: https://github.com/takahashim/rspec_telemetry/compare/v0.
|
|
77
|
+
[Unreleased]: https://github.com/takahashim/rspec_telemetry/compare/v0.6.0...HEAD
|
|
78
|
+
[0.6.0]: https://github.com/takahashim/rspec_telemetry/compare/v0.5.0...v0.6.0
|
|
61
79
|
[0.5.0]: https://github.com/takahashim/rspec_telemetry/compare/v0.4.0...v0.5.0
|
|
62
80
|
[0.4.0]: https://github.com/takahashim/rspec_telemetry/compare/v0.3.0...v0.4.0
|
|
63
81
|
[0.3.0]: https://github.com/takahashim/rspec_telemetry/compare/v0.2.0...v0.3.0
|
data/README.md
CHANGED
|
@@ -69,6 +69,10 @@ RSpecTelemetry.configure do |config|
|
|
|
69
69
|
config.output_path = "tmp/rspec_telemetry.ndjson"
|
|
70
70
|
config.capture_examples = true
|
|
71
71
|
config.capture_factory_bot = true
|
|
72
|
+
config.capture_requests = true # Rails request specs: process_action.action_controller
|
|
73
|
+
config.capture_sql = true # sql.active_record, aggregated per example
|
|
74
|
+
config.capture_gc = true # GC time / allocations per example, added to example.finished
|
|
75
|
+
config.slow_sql_threshold_ms = nil # e.g. 50.0: also record individual sql.query events above this
|
|
72
76
|
config.print_summary = false # true: print a summary to stderr at the end
|
|
73
77
|
config.flush_each = false # true: flush after each event, useful for tail -f
|
|
74
78
|
config.slow_factory_threshold_ms = nil
|
|
@@ -178,7 +182,9 @@ $ bundle exec rspec
|
|
|
178
182
|
```json
|
|
179
183
|
{"type":"example.started","example_id":"./spec/models/user_spec.rb[1:1]", ...}
|
|
180
184
|
{"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}
|
|
181
|
-
{"type":"
|
|
185
|
+
{"type":"action_controller.process_action","example_id":"./spec/requests/users_spec.rb[1:1]","controller":"UsersController","action":"show","http_method":"GET","format":"json","status":200,"duration_ms":210.5,"db_runtime_ms":85.2,"view_runtime_ms":60.1,"exception_class":null}
|
|
186
|
+
{"type":"sql.group","example_id":"./spec/requests/users_spec.rb[1:1]","name":"User Load","count":42,"total_ms":118.3,"max_ms":12.1,"in_factory_count":30,"in_factory_ms":80.2,"cached_count":3}
|
|
187
|
+
{"type":"example.finished","example_id":"./spec/models/user_spec.rb[1:1]","status":"passed","duration_ms":71.552,"gc_time_ms":4.2,"gc_count":3,"gc_major_count":0,"gc_minor_count":3,"allocated_objects":184023}
|
|
182
188
|
```
|
|
183
189
|
|
|
184
190
|
## Design notes
|
|
@@ -188,6 +194,26 @@ $ bundle exec rspec
|
|
|
188
194
|
For FactoryBot overrides, `rspec_telemetry` records only the attribute names.
|
|
189
195
|
It does not record the actual values, because they may contain personal information, secrets, or other sensitive data.
|
|
190
196
|
|
|
197
|
+
For the same reason, request events record neither `path` nor `params`:
|
|
198
|
+
query strings and parameter values may carry secrets, and `controller` + `action` already identify the endpoint.
|
|
199
|
+
|
|
200
|
+
#### SQL is aggregated per example
|
|
201
|
+
|
|
202
|
+
A single request spec example can fire thousands of queries, so writing one event per query
|
|
203
|
+
would bloat the NDJSON file and slow the suite down. Instead, queries are grouped by their
|
|
204
|
+
payload name (`"User Load"`, ...) and written as one `sql.group` event per (example, name) pair.
|
|
205
|
+
Queries fired inside a factory are counted separately (`in_factory_count` / `in_factory_ms`),
|
|
206
|
+
so factory-driven SQL can be told apart from the SQL your request actually runs.
|
|
207
|
+
`SCHEMA`, `TRANSACTION` and nameless queries are skipped; cached queries are only counted.
|
|
208
|
+
Set `slow_sql_threshold_ms` to also record individual `sql.query` events (SQL text truncated,
|
|
209
|
+
bind values never included) for queries above the threshold.
|
|
210
|
+
|
|
211
|
+
#### GC time is a trend indicator, not an exact per-example cost
|
|
212
|
+
|
|
213
|
+
`example.finished` carries the GC time, GC counts and allocated object count measured across
|
|
214
|
+
the example. GC time is process-global: an example may pay for garbage a previous example
|
|
215
|
+
allocated, so use it to spot allocation-heavy areas rather than as an exact attribution.
|
|
216
|
+
|
|
191
217
|
#### Nested factories and double counting
|
|
192
218
|
|
|
193
219
|
FactoryBot associations can create other factories internally.
|
|
@@ -14,18 +14,37 @@ module RSpecTelemetry
|
|
|
14
14
|
:duration_ms,
|
|
15
15
|
:fb_self_total_ms,
|
|
16
16
|
:fb_count,
|
|
17
|
+
:sql_total_ms,
|
|
18
|
+
:sql_count,
|
|
19
|
+
:gc_time_ms,
|
|
20
|
+
:allocated_objects,
|
|
17
21
|
keyword_init: true
|
|
18
22
|
)
|
|
19
23
|
|
|
20
24
|
FileStat = Struct.new(:file_path, :example_count, :duration_ms, keyword_init: true)
|
|
21
25
|
|
|
26
|
+
QueryStat = Struct.new(:name, :count, :total_ms, :max_ms, :in_factory_ms, keyword_init: true) do
|
|
27
|
+
def avg_ms
|
|
28
|
+
count.to_i.zero? ? 0.0 : total_ms / count
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
22
32
|
attr_reader(
|
|
23
33
|
:examples,
|
|
24
34
|
:files,
|
|
25
35
|
:example_count,
|
|
26
36
|
:failure_count,
|
|
27
37
|
:pending_count,
|
|
28
|
-
:suite_duration_ms
|
|
38
|
+
:suite_duration_ms,
|
|
39
|
+
:request_count,
|
|
40
|
+
:total_request_ms,
|
|
41
|
+
:total_db_ms,
|
|
42
|
+
:total_view_ms,
|
|
43
|
+
:sql_query_count,
|
|
44
|
+
:total_sql_ms,
|
|
45
|
+
:total_sql_in_factory_ms,
|
|
46
|
+
:total_gc_ms,
|
|
47
|
+
:total_allocated_objects
|
|
29
48
|
)
|
|
30
49
|
|
|
31
50
|
def initialize
|
|
@@ -34,10 +53,21 @@ module RSpecTelemetry
|
|
|
34
53
|
@files = {}
|
|
35
54
|
# Factory events arrive before example.finished, so merge them after loading.
|
|
36
55
|
@example_fb = Hash.new { |h, k| h[k] = [0.0, 0] }
|
|
56
|
+
@example_sql = Hash.new { |h, k| h[k] = [0.0, 0] }
|
|
57
|
+
@queries = {}
|
|
37
58
|
@example_count = 0
|
|
38
59
|
@failure_count = 0
|
|
39
60
|
@pending_count = 0
|
|
40
61
|
@suite_duration_ms = 0.0
|
|
62
|
+
@request_count = 0
|
|
63
|
+
@total_request_ms = 0.0
|
|
64
|
+
@total_db_ms = 0.0
|
|
65
|
+
@total_view_ms = 0.0
|
|
66
|
+
@sql_query_count = 0
|
|
67
|
+
@total_sql_ms = 0.0
|
|
68
|
+
@total_sql_in_factory_ms = 0.0
|
|
69
|
+
@total_gc_ms = 0.0
|
|
70
|
+
@total_allocated_objects = 0
|
|
41
71
|
end
|
|
42
72
|
|
|
43
73
|
def self.load(paths)
|
|
@@ -57,6 +87,10 @@ module RSpecTelemetry
|
|
|
57
87
|
add_example(event)
|
|
58
88
|
when "factory_bot.run_factory"
|
|
59
89
|
add_factory(event)
|
|
90
|
+
when "action_controller.process_action"
|
|
91
|
+
add_request(event)
|
|
92
|
+
when "sql.group"
|
|
93
|
+
add_sql_group(event)
|
|
60
94
|
when "suite.finished"
|
|
61
95
|
add_suite(event)
|
|
62
96
|
end
|
|
@@ -89,6 +123,9 @@ module RSpecTelemetry
|
|
|
89
123
|
self_total, count = @example_fb[ex.example_id]
|
|
90
124
|
ex.fb_self_total_ms = self_total
|
|
91
125
|
ex.fb_count = count
|
|
126
|
+
sql_total, sql_count = @example_sql[ex.example_id]
|
|
127
|
+
ex.sql_total_ms = sql_total
|
|
128
|
+
ex.sql_count = sql_count
|
|
92
129
|
ex
|
|
93
130
|
end
|
|
94
131
|
end
|
|
@@ -97,6 +134,10 @@ module RSpecTelemetry
|
|
|
97
134
|
@factory_acc.top(limit)
|
|
98
135
|
end
|
|
99
136
|
|
|
137
|
+
def top_queries(limit = 20)
|
|
138
|
+
@queries.values.sort_by { |q| -q.total_ms }.first(limit)
|
|
139
|
+
end
|
|
140
|
+
|
|
100
141
|
def slow_files(limit = 20)
|
|
101
142
|
@files.values.sort_by { |f| -f.duration_ms.to_f }.first(limit)
|
|
102
143
|
end
|
|
@@ -131,6 +172,12 @@ module RSpecTelemetry
|
|
|
131
172
|
))
|
|
132
173
|
ex.duration_ms = event["duration_ms"]
|
|
133
174
|
ex.status = event["status"]
|
|
175
|
+
if event.key?("gc_time_ms")
|
|
176
|
+
ex.gc_time_ms = event["gc_time_ms"].to_f
|
|
177
|
+
ex.allocated_objects = event["allocated_objects"].to_i
|
|
178
|
+
@total_gc_ms += ex.gc_time_ms
|
|
179
|
+
@total_allocated_objects += ex.allocated_objects
|
|
180
|
+
end
|
|
134
181
|
ex.file_path ||= event["file_path"]
|
|
135
182
|
ex.line_number ||= event["line_number"]
|
|
136
183
|
ex.full_description ||= event["full_description"]
|
|
@@ -160,6 +207,36 @@ module RSpecTelemetry
|
|
|
160
207
|
acc[1] += 1
|
|
161
208
|
end
|
|
162
209
|
|
|
210
|
+
def add_request(event)
|
|
211
|
+
@request_count += 1
|
|
212
|
+
@total_request_ms += event["duration_ms"].to_f
|
|
213
|
+
@total_db_ms += event["db_runtime_ms"].to_f
|
|
214
|
+
@total_view_ms += event["view_runtime_ms"].to_f
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def add_sql_group(event)
|
|
218
|
+
count = event["count"].to_i
|
|
219
|
+
total = event["total_ms"].to_f
|
|
220
|
+
max = event["max_ms"].to_f
|
|
221
|
+
@sql_query_count += count
|
|
222
|
+
@total_sql_ms += total
|
|
223
|
+
@total_sql_in_factory_ms += event["in_factory_ms"].to_f
|
|
224
|
+
|
|
225
|
+
name = event["name"] || "SQL"
|
|
226
|
+
q = (@queries[name] ||= QueryStat.new(name: name, count: 0, total_ms: 0.0, max_ms: 0.0, in_factory_ms: 0.0))
|
|
227
|
+
q.count += count
|
|
228
|
+
q.total_ms += total
|
|
229
|
+
q.max_ms = max if max > q.max_ms
|
|
230
|
+
q.in_factory_ms += event["in_factory_ms"].to_f
|
|
231
|
+
|
|
232
|
+
id = event["example_id"]
|
|
233
|
+
return unless id
|
|
234
|
+
|
|
235
|
+
acc = @example_sql[id]
|
|
236
|
+
acc[0] += total
|
|
237
|
+
acc[1] += count
|
|
238
|
+
end
|
|
239
|
+
|
|
163
240
|
def add_suite(event)
|
|
164
241
|
@example_count += event["example_count"].to_i
|
|
165
242
|
@failure_count += event["failure_count"].to_i
|
|
@@ -7,6 +7,10 @@ module RSpecTelemetry
|
|
|
7
7
|
:output_path,
|
|
8
8
|
:capture_examples,
|
|
9
9
|
:capture_factory_bot,
|
|
10
|
+
:capture_requests,
|
|
11
|
+
:capture_sql,
|
|
12
|
+
:capture_gc,
|
|
13
|
+
:slow_sql_threshold_ms,
|
|
10
14
|
:print_summary,
|
|
11
15
|
:flush_each,
|
|
12
16
|
:slow_factory_threshold_ms,
|
|
@@ -20,6 +24,10 @@ module RSpecTelemetry
|
|
|
20
24
|
@output_path = self.class.default_output_path
|
|
21
25
|
@capture_examples = true
|
|
22
26
|
@capture_factory_bot = true
|
|
27
|
+
@capture_requests = true
|
|
28
|
+
@capture_sql = true
|
|
29
|
+
@capture_gc = true
|
|
30
|
+
@slow_sql_threshold_ms = nil
|
|
23
31
|
@print_summary = false
|
|
24
32
|
@flush_each = false
|
|
25
33
|
@slow_factory_threshold_ms = nil
|
|
@@ -13,6 +13,10 @@ module RSpecTelemetry
|
|
|
13
13
|
|
|
14
14
|
def truncate(str, len) = str.length > len ? "#{str[0, len - 1]}…" : str
|
|
15
15
|
|
|
16
|
+
def objects(count)
|
|
17
|
+
count >= 1_000_000 ? format("%.1fM", count / 1_000_000.0) : count.to_s
|
|
18
|
+
end
|
|
19
|
+
|
|
16
20
|
def section(title) = ["", title, "-" * title.length]
|
|
17
21
|
end
|
|
18
22
|
|
|
@@ -25,7 +29,7 @@ module RSpecTelemetry
|
|
|
25
29
|
end
|
|
26
30
|
|
|
27
31
|
def render
|
|
28
|
-
lines = overview + slow_files + slow_examples + top_factories
|
|
32
|
+
lines = overview + slow_files + slow_examples + top_factories + top_queries
|
|
29
33
|
lines << ""
|
|
30
34
|
lines << "Tip: drill into one example with `rspec-telemetry --example \"<example_id>\"`"
|
|
31
35
|
lines.join("\n")
|
|
@@ -37,6 +41,38 @@ module RSpecTelemetry
|
|
|
37
41
|
if finished
|
|
38
42
|
lines << " #{finished["full_description"]}"
|
|
39
43
|
lines << " status: #{finished["status"]} duration: #{Helpers.fmt(finished["duration_ms"])}"
|
|
44
|
+
if finished["gc_time_ms"]
|
|
45
|
+
lines << " gc: #{Helpers.fmt(finished["gc_time_ms"])} " \
|
|
46
|
+
"(#{finished["gc_major_count"]} major / #{finished["gc_minor_count"]} minor, " \
|
|
47
|
+
"#{Helpers.objects(finished["allocated_objects"].to_i)} objects allocated)"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
requests = events.select { |e| e["type"] == "action_controller.process_action" }
|
|
52
|
+
unless requests.empty?
|
|
53
|
+
lines << ""
|
|
54
|
+
lines << " Requests:"
|
|
55
|
+
requests.each { |r| lines << request_line(r) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
sql_groups = events.select { |e| e["type"] == "sql.group" }
|
|
59
|
+
unless sql_groups.empty?
|
|
60
|
+
lines << ""
|
|
61
|
+
lines << " SQL (grouped by name):"
|
|
62
|
+
sql_groups.sort_by { |g| -g["total_ms"].to_f }.each { |g| lines << sql_group_line(g) }
|
|
63
|
+
total = sql_groups.sum { |g| g["total_ms"].to_f }
|
|
64
|
+
count = sql_groups.sum { |g| g["count"].to_i }
|
|
65
|
+
lines << " sql total: #{Helpers.fmt(total)} across #{count} queries"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
slow_queries = events.select { |e| e["type"] == "sql.query" }
|
|
69
|
+
unless slow_queries.empty?
|
|
70
|
+
lines << ""
|
|
71
|
+
lines << " Slow queries:"
|
|
72
|
+
slow_queries.each do |q|
|
|
73
|
+
lines << " #{Helpers.fmt(q["duration_ms"])} #{q["name"]}#{q["in_factory"] ? " (in factory)" : ""}"
|
|
74
|
+
lines << " #{q["sql"]}"
|
|
75
|
+
end
|
|
40
76
|
end
|
|
41
77
|
|
|
42
78
|
factories = events.select { |e| e["type"] == "factory_bot.run_factory" }
|
|
@@ -51,6 +87,31 @@ module RSpecTelemetry
|
|
|
51
87
|
lines.join("\n")
|
|
52
88
|
end
|
|
53
89
|
|
|
90
|
+
def self.sql_group_line(fields)
|
|
91
|
+
in_factory = fields["in_factory_count"].to_i
|
|
92
|
+
factory_note = in_factory.positive? ? " (in factories: #{Helpers.fmt(fields["in_factory_ms"])} / #{in_factory})" : ""
|
|
93
|
+
format(
|
|
94
|
+
" %-32s count %4d total %9s max %9s%s",
|
|
95
|
+
Helpers.truncate(fields["name"].to_s, 32),
|
|
96
|
+
fields["count"].to_i,
|
|
97
|
+
Helpers.fmt(fields["total_ms"]),
|
|
98
|
+
Helpers.fmt(fields["max_ms"]),
|
|
99
|
+
factory_note
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.request_line(fields)
|
|
104
|
+
db = fields["db_runtime_ms"]
|
|
105
|
+
view = fields["view_runtime_ms"]
|
|
106
|
+
breakdown = [
|
|
107
|
+
db ? "db #{Helpers.fmt(db)}" : nil,
|
|
108
|
+
view ? "view #{Helpers.fmt(view)}" : nil
|
|
109
|
+
].compact.join(", ")
|
|
110
|
+
breakdown = " (#{breakdown})" unless breakdown.empty?
|
|
111
|
+
" #{fields["http_method"]} #{fields["controller"]}##{fields["action"]} " \
|
|
112
|
+
"status #{fields["status"]} #{Helpers.fmt(fields["duration_ms"])}#{breakdown}"
|
|
113
|
+
end
|
|
114
|
+
|
|
54
115
|
def self.factory_line(fields)
|
|
55
116
|
indent = " " + (" " * fields["depth"].to_i)
|
|
56
117
|
traits = Array(fields["traits"]).empty? ? "" : " [#{fields["traits"].join(",")}]"
|
|
@@ -63,7 +124,7 @@ module RSpecTelemetry
|
|
|
63
124
|
|
|
64
125
|
def overview
|
|
65
126
|
a = @analyzer
|
|
66
|
-
section("Overview") +
|
|
127
|
+
lines = section("Overview") +
|
|
67
128
|
[
|
|
68
129
|
" files analyzed: #{@files_count}",
|
|
69
130
|
" examples: #{a.example_count} (#{a.failure_count} failed, #{a.pending_count} pending)",
|
|
@@ -71,6 +132,21 @@ module RSpecTelemetry
|
|
|
71
132
|
" example time (sum): #{fmt(a.total_example_ms)}",
|
|
72
133
|
" factory self time: #{fmt(a.total_factory_self_ms)} (#{pct(a.factory_time_ratio)} of example time)"
|
|
73
134
|
]
|
|
135
|
+
if a.request_count.positive?
|
|
136
|
+
lines << " request time (sum): #{fmt(a.total_request_ms)} across #{a.request_count} requests " \
|
|
137
|
+
"(db: #{fmt(a.total_db_ms)}, view: #{fmt(a.total_view_ms)})"
|
|
138
|
+
end
|
|
139
|
+
if a.sql_query_count.positive?
|
|
140
|
+
lines << " sql time (sum): #{fmt(a.total_sql_ms)} across #{a.sql_query_count} queries " \
|
|
141
|
+
"(in factories: #{fmt(a.total_sql_in_factory_ms)}, " \
|
|
142
|
+
"elsewhere: #{fmt(a.total_sql_ms - a.total_sql_in_factory_ms)})"
|
|
143
|
+
end
|
|
144
|
+
if a.total_gc_ms.positive?
|
|
145
|
+
ratio = a.total_example_ms.zero? ? 0.0 : a.total_gc_ms / a.total_example_ms
|
|
146
|
+
lines << " gc time (sum): #{fmt(a.total_gc_ms)} (#{pct(ratio)} of example time, " \
|
|
147
|
+
"#{objects(a.total_allocated_objects)} objects allocated)"
|
|
148
|
+
end
|
|
149
|
+
lines
|
|
74
150
|
end
|
|
75
151
|
|
|
76
152
|
def slow_files
|
|
@@ -90,8 +166,34 @@ module RSpecTelemetry
|
|
|
90
166
|
lines = section("Slowest examples")
|
|
91
167
|
rows.each_with_index do |e, i|
|
|
92
168
|
fb = e.fb_count.to_i.positive? ? " [factories: #{fmt(e.fb_self_total_ms)} / #{e.fb_count} calls]" : ""
|
|
169
|
+
sql = e.sql_count.to_i.positive? ? " [sql: #{fmt(e.sql_total_ms)} / #{e.sql_count} queries]" : ""
|
|
170
|
+
gc = e.gc_time_ms.to_f.positive? ? " [gc: #{fmt(e.gc_time_ms)}]" : ""
|
|
93
171
|
lines << format(" %2d. %-9s %s", i + 1, fmt(e.duration_ms), e.example_id)
|
|
94
|
-
|
|
172
|
+
annotations = "#{fb}#{sql}#{gc}"
|
|
173
|
+
lines << " #{e.full_description}#{annotations}" if e.full_description || !annotations.empty?
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
lines
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def top_queries
|
|
180
|
+
rows = @analyzer.top_queries(@top)
|
|
181
|
+
return [] if rows.empty?
|
|
182
|
+
|
|
183
|
+
lines = section("Slowest queries (grouped by name, cached queries excluded)")
|
|
184
|
+
lines << format(" %-28s %8s %10s %9s %9s %12s", "query", "count", "total", "avg", "max", "in factories")
|
|
185
|
+
rows.each_with_index do |q, i|
|
|
186
|
+
lines <<
|
|
187
|
+
format(
|
|
188
|
+
" %2d. %-28s %8d %10s %9s %9s %12s",
|
|
189
|
+
i + 1,
|
|
190
|
+
truncate(q.name, 28),
|
|
191
|
+
q.count,
|
|
192
|
+
fmt(q.total_ms),
|
|
193
|
+
fmt(q.avg_ms),
|
|
194
|
+
fmt(q.max_ms),
|
|
195
|
+
fmt(q.in_factory_ms)
|
|
196
|
+
)
|
|
95
197
|
end
|
|
96
198
|
|
|
97
199
|
lines
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "rspec/core"
|
|
4
4
|
require "rspec/core/formatters/base_formatter"
|
|
5
5
|
|
|
6
|
+
require_relative "gc_sample"
|
|
7
|
+
|
|
6
8
|
module RSpecTelemetry
|
|
7
9
|
class Formatter < RSpec::Core::Formatters::BaseFormatter
|
|
8
10
|
RSpec::Core::Formatters.register(
|
|
@@ -23,6 +25,7 @@ module RSpecTelemetry
|
|
|
23
25
|
example = notification.example
|
|
24
26
|
# Runs before before(:each), so FactoryBot calls inside hooks get this example_id.
|
|
25
27
|
recorder.set_current_example(example.id)
|
|
28
|
+
@gc_before = config.capture_gc ? GcSample.take : nil
|
|
26
29
|
next unless config.capture_examples
|
|
27
30
|
|
|
28
31
|
recorder.record(
|
|
@@ -37,6 +40,9 @@ module RSpecTelemetry
|
|
|
37
40
|
def example_finished(notification)
|
|
38
41
|
RSpecTelemetry.safely("formatter#example_finished") do
|
|
39
42
|
example = notification.example
|
|
43
|
+
# Flush per-example aggregates (sql.group) before example.finished so
|
|
44
|
+
# they land inside the example's event block, like factory events do.
|
|
45
|
+
RSpecTelemetry.flush_example_events!
|
|
40
46
|
record_example_finished(example) if config.capture_examples
|
|
41
47
|
recorder.flush
|
|
42
48
|
ensure
|
|
@@ -68,8 +74,7 @@ module RSpecTelemetry
|
|
|
68
74
|
result = example.execution_result
|
|
69
75
|
exception = result.exception
|
|
70
76
|
|
|
71
|
-
|
|
72
|
-
"example.finished",
|
|
77
|
+
fields = {
|
|
73
78
|
file_path: example.file_path,
|
|
74
79
|
line_number: example.metadata[:line_number],
|
|
75
80
|
full_description: example.full_description,
|
|
@@ -77,7 +82,14 @@ module RSpecTelemetry
|
|
|
77
82
|
duration_ms: (result.run_time.to_f * 1000.0).round(3),
|
|
78
83
|
exception_class: exception&.class&.name,
|
|
79
84
|
exception_message: exception&.message
|
|
80
|
-
|
|
85
|
+
}
|
|
86
|
+
if config.capture_gc
|
|
87
|
+
gc = GcSample.delta(@gc_before, GcSample.take)
|
|
88
|
+
fields.merge!(gc) if gc
|
|
89
|
+
@gc_before = nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
recorder.record("example.finished", fields)
|
|
81
93
|
end
|
|
82
94
|
|
|
83
95
|
def recorder
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTelemetry
|
|
4
|
+
# Snapshots GC counters around an example so example.finished can carry the
|
|
5
|
+
# GC cost of that example. GC time is process-global: an example may pay for
|
|
6
|
+
# garbage a previous example allocated, so treat the attribution as a trend
|
|
7
|
+
# indicator, not an exact per-example cost.
|
|
8
|
+
module GcSample
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def take
|
|
12
|
+
stat = GC.stat
|
|
13
|
+
{
|
|
14
|
+
total_time_ns: GC.total_time,
|
|
15
|
+
count: stat[:count],
|
|
16
|
+
major: stat[:major_gc_count],
|
|
17
|
+
minor: stat[:minor_gc_count],
|
|
18
|
+
allocated: stat[:total_allocated_objects]
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def delta(before, after)
|
|
23
|
+
return nil unless before && after
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
gc_time_ms: ((after[:total_time_ns] - before[:total_time_ns]) / 1_000_000.0).round(3),
|
|
27
|
+
gc_count: after[:count] - before[:count],
|
|
28
|
+
gc_major_count: after[:major] - before[:major],
|
|
29
|
+
gc_minor_count: after[:minor] - before[:minor],
|
|
30
|
+
allocated_objects: after[:allocated] - before[:allocated]
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
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 ActionController
|
|
15
|
+
STACK_KEY = :rspec_telemetry_ac_stack
|
|
16
|
+
|
|
17
|
+
def initialize(recorder)
|
|
18
|
+
@recorder = recorder
|
|
19
|
+
@subscription = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def subscribe
|
|
23
|
+
@subscription = ActiveSupport::Notifications.subscribe("process_action.action_controller", 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("action_controller#start") do
|
|
33
|
+
stack.push(Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def finish(_name, _id, payload)
|
|
38
|
+
RSpecTelemetry.safely("action_controller#finish") do
|
|
39
|
+
started = stack.pop
|
|
40
|
+
next unless started
|
|
41
|
+
next unless @recorder.config.enabled && @recorder.config.capture_requests
|
|
42
|
+
|
|
43
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
44
|
+
|
|
45
|
+
# Deliberately no :path / :params: query strings and parameter values may
|
|
46
|
+
# carry secrets, and controller#action already identifies the endpoint.
|
|
47
|
+
@recorder.record(
|
|
48
|
+
"action_controller.process_action",
|
|
49
|
+
controller: payload[:controller],
|
|
50
|
+
action: payload[:action],
|
|
51
|
+
http_method: payload[:method],
|
|
52
|
+
format: payload[:format].to_s,
|
|
53
|
+
status: payload[:status],
|
|
54
|
+
duration_ms: ((now - started) * 1000.0).round(3),
|
|
55
|
+
db_runtime_ms: runtime_ms(payload[:db_runtime]),
|
|
56
|
+
view_runtime_ms: runtime_ms(payload[:view_runtime]),
|
|
57
|
+
exception_class: Array(payload[:exception]).first
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def runtime_ms(value)
|
|
65
|
+
value.nil? ? nil : value.to_f.round(3)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def stack
|
|
69
|
+
Thread.current[STACK_KEY] ||= []
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
require_relative "../recorder"
|
|
13
|
+
|
|
14
|
+
module RSpecTelemetry
|
|
15
|
+
module Subscribers
|
|
16
|
+
# Aggregates sql.active_record per example instead of writing one event per
|
|
17
|
+
# query: a request spec can fire thousands of queries per example, which
|
|
18
|
+
# would bloat the NDJSON file and slow the suite down. Queries are grouped
|
|
19
|
+
# by payload name ("Merchant Load", ...) and flushed as one sql.group event
|
|
20
|
+
# per (example, name) when the lifecycle calls #example_finished.
|
|
21
|
+
class Sql
|
|
22
|
+
STACK_KEY = :rspec_telemetry_sql_stack
|
|
23
|
+
# Mirrors Subscribers::FactoryBot::STACK_KEY without forcing that file to
|
|
24
|
+
# load; a non-empty stack means this query runs inside a factory.
|
|
25
|
+
FB_STACK_KEY = :rspec_telemetry_fb_stack
|
|
26
|
+
|
|
27
|
+
# SCHEMA is Rails introspection noise; TRANSACTION (BEGIN/COMMIT) mostly
|
|
28
|
+
# measures the test's own transaction wrapping. Nameless queries (raw
|
|
29
|
+
# #execute, and BEGIN/COMMIT on Rails < 7.1) are skipped too, because they
|
|
30
|
+
# cannot be told apart from transaction management reliably.
|
|
31
|
+
SKIP_NAMES = %w[SCHEMA TRANSACTION].freeze
|
|
32
|
+
|
|
33
|
+
SQL_TRUNCATE = 500
|
|
34
|
+
|
|
35
|
+
def initialize(recorder)
|
|
36
|
+
@recorder = recorder
|
|
37
|
+
@subscription = nil
|
|
38
|
+
# Queries can arrive from threads other than the example's (e.g. a
|
|
39
|
+
# Capybara app server), so buckets are shared and keyed by example_id.
|
|
40
|
+
@mutex = Mutex.new
|
|
41
|
+
@buckets = {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def subscribe
|
|
45
|
+
@subscription = ActiveSupport::Notifications.subscribe("sql.active_record", self)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def unsubscribe
|
|
49
|
+
ActiveSupport::Notifications.unsubscribe(@subscription) if @subscription
|
|
50
|
+
@subscription = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def start(_name, _id, _payload)
|
|
54
|
+
RSpecTelemetry.safely("sql#start") do
|
|
55
|
+
stack.push(Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def finish(_name, _id, payload)
|
|
60
|
+
RSpecTelemetry.safely("sql#finish") do
|
|
61
|
+
started = stack.pop
|
|
62
|
+
next unless started
|
|
63
|
+
|
|
64
|
+
config = @recorder.config
|
|
65
|
+
next unless config.enabled && config.capture_sql
|
|
66
|
+
|
|
67
|
+
name = payload[:name]
|
|
68
|
+
next if name.nil? || SKIP_NAMES.include?(name)
|
|
69
|
+
|
|
70
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
71
|
+
ms = (now - started) * 1000.0
|
|
72
|
+
cached = payload[:cached] ? true : false
|
|
73
|
+
fb_stack = Thread.current[FB_STACK_KEY]
|
|
74
|
+
in_factory = fb_stack && !fb_stack.empty?
|
|
75
|
+
|
|
76
|
+
accumulate(name, ms, cached: cached, in_factory: in_factory)
|
|
77
|
+
record_slow_query(name, ms, payload, in_factory) unless cached
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Called by the lifecycle at example end, while the example id is still
|
|
82
|
+
# set, so the flushed sql.group events attach to the right example.
|
|
83
|
+
def example_finished
|
|
84
|
+
RSpecTelemetry.safely("sql#example_finished") do
|
|
85
|
+
example_id = Thread.current[Recorder::EXAMPLE_ID]
|
|
86
|
+
groups = @mutex.synchronize { @buckets.delete(example_id) }
|
|
87
|
+
next unless groups
|
|
88
|
+
|
|
89
|
+
groups.each do |name, g|
|
|
90
|
+
@recorder.record(
|
|
91
|
+
"sql.group",
|
|
92
|
+
name: name,
|
|
93
|
+
count: g[:count],
|
|
94
|
+
total_ms: g[:total_ms].round(3),
|
|
95
|
+
max_ms: g[:max_ms].round(3),
|
|
96
|
+
in_factory_count: g[:in_factory_count],
|
|
97
|
+
in_factory_ms: g[:in_factory_ms].round(3),
|
|
98
|
+
cached_count: g[:cached_count]
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def accumulate(name, ms, cached:, in_factory:)
|
|
107
|
+
example_id = Thread.current[Recorder::EXAMPLE_ID]
|
|
108
|
+
@mutex.synchronize do
|
|
109
|
+
groups = (@buckets[example_id] ||= {})
|
|
110
|
+
g = (groups[name] ||= {
|
|
111
|
+
count: 0, total_ms: 0.0, max_ms: 0.0,
|
|
112
|
+
in_factory_count: 0, in_factory_ms: 0.0, cached_count: 0
|
|
113
|
+
})
|
|
114
|
+
if cached
|
|
115
|
+
g[:cached_count] += 1
|
|
116
|
+
next
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
g[:count] += 1
|
|
120
|
+
g[:total_ms] += ms
|
|
121
|
+
g[:max_ms] = ms if ms > g[:max_ms]
|
|
122
|
+
next unless in_factory
|
|
123
|
+
|
|
124
|
+
g[:in_factory_count] += 1
|
|
125
|
+
g[:in_factory_ms] += ms
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def record_slow_query(name, ms, payload, in_factory)
|
|
130
|
+
threshold = @recorder.config.slow_sql_threshold_ms
|
|
131
|
+
return unless threshold && ms >= threshold
|
|
132
|
+
|
|
133
|
+
@recorder.record(
|
|
134
|
+
"sql.query",
|
|
135
|
+
name: name,
|
|
136
|
+
duration_ms: ms.round(3),
|
|
137
|
+
sql: payload[:sql].to_s[0, SQL_TRUNCATE],
|
|
138
|
+
in_factory: in_factory
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def stack
|
|
143
|
+
Thread.current[STACK_KEY] ||= []
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
data/lib/rspec_telemetry.rb
CHANGED
|
@@ -42,6 +42,12 @@ module RSpecTelemetry
|
|
|
42
42
|
unsubscribe!
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# Lets subscribers that aggregate per example (e.g. SQL) flush their events
|
|
46
|
+
# while the example id is still set on the recorder.
|
|
47
|
+
def flush_example_events!
|
|
48
|
+
@subscribers&.each { |s| s.example_finished if s.respond_to?(:example_finished) }
|
|
49
|
+
end
|
|
50
|
+
|
|
45
51
|
def reset!
|
|
46
52
|
unsubscribe!
|
|
47
53
|
@config = nil
|
|
@@ -71,30 +77,33 @@ module RSpecTelemetry
|
|
|
71
77
|
end
|
|
72
78
|
|
|
73
79
|
def subscribe!
|
|
74
|
-
return
|
|
75
|
-
return if @factory_bot_subscriber
|
|
80
|
+
return if @subscribers
|
|
76
81
|
|
|
77
|
-
|
|
78
|
-
|
|
82
|
+
subscribers = []
|
|
83
|
+
subscribers << build_subscriber("factory_bot", :FactoryBot) if config.capture_factory_bot
|
|
84
|
+
subscribers << build_subscriber("action_controller", :ActionController) if config.capture_requests
|
|
85
|
+
subscribers << build_subscriber("sql", :Sql) if config.capture_sql
|
|
86
|
+
subscribers.compact!
|
|
87
|
+
return if subscribers.empty?
|
|
79
88
|
|
|
80
|
-
@
|
|
81
|
-
@
|
|
89
|
+
@subscribers = subscribers
|
|
90
|
+
@subscribers.each(&:subscribe)
|
|
82
91
|
end
|
|
83
92
|
|
|
84
|
-
# activesupport is an optional dependency:
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
def
|
|
89
|
-
require_relative "rspec_telemetry/subscribers
|
|
90
|
-
Subscribers
|
|
93
|
+
# activesupport is an optional dependency: subscribers rely on
|
|
94
|
+
# ActiveSupport::Notifications, which the instrumented libraries (FactoryBot,
|
|
95
|
+
# Rails) pull in themselves. When it is absent there are no events to capture
|
|
96
|
+
# and we silently skip the subscription.
|
|
97
|
+
def build_subscriber(file, const)
|
|
98
|
+
require_relative "rspec_telemetry/subscribers/#{file}"
|
|
99
|
+
Subscribers.const_get(const).new(recorder)
|
|
91
100
|
rescue LoadError
|
|
92
101
|
nil
|
|
93
102
|
end
|
|
94
103
|
|
|
95
104
|
def unsubscribe!
|
|
96
|
-
@
|
|
97
|
-
@
|
|
105
|
+
@subscribers&.each(&:unsubscribe)
|
|
106
|
+
@subscribers = nil
|
|
98
107
|
end
|
|
99
108
|
end
|
|
100
109
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rspec_telemetry
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- takahashimm
|
|
@@ -37,7 +37,8 @@ dependencies:
|
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '0.4'
|
|
40
|
-
description: Collect RSpec
|
|
40
|
+
description: 'Collect RSpec telemetry as NDJSON to find slow tests: FactoryBot calls,
|
|
41
|
+
Rails requests, SQL time and GC cost, broken down per example.'
|
|
41
42
|
email:
|
|
42
43
|
- takahashimm@gmail.com
|
|
43
44
|
executables:
|
|
@@ -64,9 +65,12 @@ files:
|
|
|
64
65
|
- lib/rspec_telemetry/factory_comparison.rb
|
|
65
66
|
- lib/rspec_telemetry/formatter.rb
|
|
66
67
|
- lib/rspec_telemetry/formatting.rb
|
|
68
|
+
- lib/rspec_telemetry/gc_sample.rb
|
|
67
69
|
- lib/rspec_telemetry/ndjson.rb
|
|
68
70
|
- lib/rspec_telemetry/recorder.rb
|
|
71
|
+
- lib/rspec_telemetry/subscribers/action_controller.rb
|
|
69
72
|
- lib/rspec_telemetry/subscribers/factory_bot.rb
|
|
73
|
+
- lib/rspec_telemetry/subscribers/sql.rb
|
|
70
74
|
- lib/rspec_telemetry/summary.rb
|
|
71
75
|
- lib/rspec_telemetry/trace/viewer.rb
|
|
72
76
|
- lib/rspec_telemetry/trace/viewer/app.rb
|
|
@@ -102,7 +106,7 @@ metadata:
|
|
|
102
106
|
allowed_push_host: https://rubygems.org
|
|
103
107
|
homepage_uri: https://github.com/takahashim/rspec_telemetry
|
|
104
108
|
source_code_uri: https://github.com/takahashim/rspec_telemetry
|
|
105
|
-
changelog_uri: https://github.com/takahashim/rspec_telemetry
|
|
109
|
+
changelog_uri: https://github.com/takahashim/rspec_telemetry/blob/main/CHANGELOG.md
|
|
106
110
|
rdoc_options: []
|
|
107
111
|
require_paths:
|
|
108
112
|
- lib
|
|
@@ -119,5 +123,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
119
123
|
requirements: []
|
|
120
124
|
rubygems_version: 3.6.9
|
|
121
125
|
specification_version: 4
|
|
122
|
-
summary: Collect RSpec
|
|
126
|
+
summary: Collect RSpec telemetry as NDJSON to find slow tests.
|
|
123
127
|
test_files: []
|