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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 796f640dcbe14855620f29d78972456705d29af7025b3e01171e9f4d76dfca04
4
- data.tar.gz: 271548c28826a191510187593eaef599771f964c16b3061c7e6d6751e4dbd7fd
3
+ metadata.gz: cbedbd6b59f7770b4994ee3f8cc9d475f27b606b391e0a63ee8bae67c5ca9098
4
+ data.tar.gz: e6119f45762763a2b3aa2051085bf2b1d6933c664e186b2a55d45622dd0b59ff
5
5
  SHA512:
6
- metadata.gz: 960b867f5af3ed3a290d637422dee0436ec10b72b7554a9bb348329cd7ba561dbeaca388148cb41f39c1e616708f570d8c2466855f6321cbd1f9bfbf4861e464
7
- data.tar.gz: 1ddef07a9486a993f55073db7a717e185dba269cacc4f379dc43e117b42b35edebb09a07d81a2be9d652a07eb2208fced3e13faff4f3e70a82a78a71780c577b
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.5.0...HEAD
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":"example.finished","example_id":"./spec/models/user_spec.rb[1:1]","status":"passed","duration_ms":71.552}
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
- lines << " #{e.full_description}#{fb}" if e.full_description || !fb.empty?
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
- recorder.record(
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RSpecTelemetry
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -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 unless config.capture_factory_bot
75
- return if @factory_bot_subscriber
80
+ return if @subscribers
76
81
 
77
- subscriber = build_factory_bot_subscriber
78
- return unless subscriber
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
- @factory_bot_subscriber = subscriber
81
- @factory_bot_subscriber.subscribe
89
+ @subscribers = subscribers
90
+ @subscribers.each(&:subscribe)
82
91
  end
83
92
 
84
- # activesupport is an optional dependency: it is only needed for FactoryBot
85
- # tracking, which relies on ActiveSupport::Notifications. FactoryBot itself
86
- # pulls in activesupport, so when it is absent there are no factory events to
87
- # capture and we silently skip the subscription.
88
- def build_factory_bot_subscriber
89
- require_relative "rspec_telemetry/subscribers/factory_bot"
90
- Subscribers::FactoryBot.new(recorder)
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
- @factory_bot_subscriber&.unsubscribe
97
- @factory_bot_subscriber = nil
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.5.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 / FactoryBot telemetry as NDJSON to find slow tests.
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 / FactoryBot telemetry as NDJSON to find slow tests.
126
+ summary: Collect RSpec telemetry as NDJSON to find slow tests.
123
127
  test_files: []