rspec_telemetry 0.3.0 → 0.4.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: a39ae80facd19b25cfcbb59d34bde9ad51d47cb815d1ea5bfec9b3a344cead99
4
- data.tar.gz: 031a509323010facb690d5cedbc53eb3e318087cbb69439832426b24673efa98
3
+ metadata.gz: 5c0f4a3e6ec69cd97b7aa3c4a5160d354339e30cdd0f9954dee2866674fdef68
4
+ data.tar.gz: f5ceb63cd260c16f06fb9b51e6ce843c89f141b9f9ae2ae246cc559e0d41022e
5
5
  SHA512:
6
- metadata.gz: 8158f6494ea9a691a3c52c236320cfffd6b32986258ab578a87dc708940c800a5f7fde9342187240fb254158d24bed74edf7d39963856c39488ce468f78f898a
7
- data.tar.gz: 6d962956bde3f0a320aec11562d584380da45c9c6372a589c1e12cfc5d6361965327624cdfbd6eea08cc6333e7688530095cb91964cce9c8204e2ff2dd3ed611
6
+ metadata.gz: 1070d3497148bcaf1241ce4b30228c3b6089327194b93c52ec94aff5ccd720af11dc6c10d9fda5d4e9dd868298861aff712345c8104d1d0f3af7a26db9cb3c0c
7
+ data.tar.gz: ee999d564466cac64f328b95503f1e3161fbd850b1badff30691b99d48baae5e357491b64a0dfb84c134b92a3abff4e5990c6e2b5f5d601c4670adfce7286c9a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [0.4.0] - 2026-06-30
6
+
7
+ ### Added
8
+ - `rspec-telemetry-compare --by-factory` to combine strategies (create/build)
9
+ and compare by factory name only. Works together with `--all-depths`.
10
+ - `rspec-telemetry-compare` now prints a `TOTAL` row summing all factories.
11
+
12
+ ### Changed
13
+ - `rspec-telemetry-compare` now reuses `FactoryAggregation` and keys rows by
14
+ `factory:strategy`, so create and build of the same factory are compared
15
+ separately.
16
+
3
17
  ## [0.3.0] - 2026-06-25
4
18
 
5
19
  ### Added
@@ -30,6 +44,8 @@
30
44
 
31
45
  First public release.
32
46
 
33
- [Unreleased]: https://github.com/takahashim/rspec_telemetry/compare/v0.2.0...HEAD
47
+ [Unreleased]: https://github.com/takahashim/rspec_telemetry/compare/v0.4.0...HEAD
48
+ [0.4.0]: https://github.com/takahashim/rspec_telemetry/compare/v0.3.0...v0.4.0
49
+ [0.3.0]: https://github.com/takahashim/rspec_telemetry/compare/v0.2.0...v0.3.0
34
50
  [0.2.0]: https://github.com/takahashim/rspec_telemetry/compare/v0.1.0...v0.2.0
35
51
  [0.1.0]: https://github.com/takahashim/rspec_telemetry/releases/tag/v0.1.0
data/README.md CHANGED
@@ -120,6 +120,10 @@ $ bundle exec rspec-telemetry-compare \
120
120
  tmp/rspec_telemetry.after.ndjson
121
121
  ```
122
122
 
123
+ Factories are grouped by `factory:strategy`, so `user:create` and `user:build`
124
+ are compared separately. Use `--by-factory` to combine strategies and compare by
125
+ factory name only (e.g. one `user` row).
126
+
123
127
  By default, only root factory events (`depth == 0`) are counted, and their
124
128
  inclusive `duration_ms` is compared.
125
129
 
@@ -131,6 +135,8 @@ counting while showing the actual number and cost of associated factories.
131
135
  rspec-telemetry-compare --sort count BEFORE AFTER
132
136
  rspec-telemetry-compare --sort factory BEFORE AFTER
133
137
  rspec-telemetry-compare --all-depths BEFORE AFTER
138
+ rspec-telemetry-compare --by-factory BEFORE AFTER # combine create/build
139
+ rspec-telemetry-compare --all-depths --by-factory BEFORE AFTER
134
140
  ```
135
141
 
136
142
  ## TUI viewer: `rspec-telemetry-viewer`
@@ -3,6 +3,7 @@
3
3
  require "optparse"
4
4
 
5
5
  require_relative "factory_comparison"
6
+ require_relative "formatting"
6
7
 
7
8
  module RSpecTelemetry
8
9
  class CompareCLI
@@ -10,7 +11,7 @@ module RSpecTelemetry
10
11
  @argv = argv
11
12
  @out = out
12
13
  @err = err
13
- @options = {all_depths: false, sort: "duration"}
14
+ @options = {all_depths: false, by_factory: false, sort: "duration"}
14
15
  end
15
16
 
16
17
  def run
@@ -20,9 +21,13 @@ module RSpecTelemetry
20
21
  return 1
21
22
  end
22
23
 
23
- comparison = FactoryComparison.new(paths[0], paths[1], all_depths: @options[:all_depths])
24
+ comparison = FactoryComparison.new(
25
+ paths[0], paths[1],
26
+ all_depths: @options[:all_depths],
27
+ by_factory: @options[:by_factory]
28
+ )
24
29
  rows = sort_rows(comparison.rows)
25
- @out.puts(render(rows, duration_label: comparison.all_depths ? "Self(ms)" : "Total(ms)"))
30
+ @out.puts(render(rows, duration_label: comparison.duration_label, label_heading: label_heading))
26
31
  0
27
32
  rescue Errno::ENOENT => e
28
33
  @err.puts("File not found: #{e.message}")
@@ -40,6 +45,9 @@ module RSpecTelemetry
40
45
  options.on("--all-depths", "Include nested FactoryBot events") do
41
46
  @options[:all_depths] = true
42
47
  end
48
+ options.on("--by-factory", "Combine strategies (create/build) per factory") do
49
+ @options[:by_factory] = true
50
+ end
43
51
  options.on(
44
52
  "--sort KEY",
45
53
  %w[duration count factory],
@@ -59,17 +67,21 @@ module RSpecTelemetry
59
67
  def sort_rows(rows)
60
68
  case @options[:sort]
61
69
  when "count"
62
- rows.sort_by { |row| [-row.count_diff.abs, row.factory] }
70
+ rows.sort_by { |row| [-row.count_diff.abs, row.label] }
63
71
  when "factory"
64
- rows.sort_by(&:factory)
72
+ rows.sort_by(&:label)
65
73
  else
66
- rows.sort_by { |row| [-row.duration_diff_ms.abs, row.factory] }
74
+ rows.sort_by { |row| [-row.duration_diff_ms.abs, row.label] }
67
75
  end
68
76
  end
69
77
 
70
- def render(rows, duration_label:)
78
+ def label_heading
79
+ @options[:by_factory] ? "Factory" : "Factory:Strategy"
80
+ end
81
+
82
+ def render(rows, duration_label:, label_heading:)
71
83
  headings = [
72
- "Factory",
84
+ label_heading,
73
85
  "Before",
74
86
  "After",
75
87
  "Diff",
@@ -79,51 +91,61 @@ module RSpecTelemetry
79
91
  "Diff(ms)",
80
92
  "Change"
81
93
  ]
82
- body = rows.map do |row|
83
- [
84
- row.factory,
85
- row.before_count.to_s,
86
- row.after_count.to_s,
87
- signed_integer(row.count_diff),
88
- percent(row.count_change_percent),
89
- decimal(row.before_duration_ms),
90
- decimal(row.after_duration_ms),
91
- signed_decimal(row.duration_diff_ms),
92
- percent(row.duration_change_percent)
93
- ]
94
- end
94
+ body = rows.map { |row| columns_for(row) }
95
+ total = columns_for(totals_row(rows))
95
96
 
96
97
  widths = headings.each_index.map do |index|
97
- ([headings[index]] + body.map { |columns| columns[index] }).map(&:length).max
98
+ ([headings[index]] + (body + [total]).map { |columns| columns[index] }).map(&:length).max
98
99
  end
99
100
 
100
101
  lines = []
101
102
  lines << format_row(headings, widths)
102
- lines << widths.map { |width| "-" * width }.join("-+-")
103
+ lines << separator(widths)
103
104
  body.each { |columns| lines << format_row(columns, widths) }
105
+ lines << separator(widths)
106
+ lines << format_row(total, widths)
104
107
  lines.join("\n")
105
108
  end
106
109
 
107
- def format_row(columns, widths)
108
- columns.each_with_index.map do |value, index|
109
- index.zero? ? value.ljust(widths[index]) : value.rjust(widths[index])
110
- end.join(" | ")
110
+ def columns_for(row)
111
+ [
112
+ row.label,
113
+ row.before_count.to_s,
114
+ row.after_count.to_s,
115
+ Formatting.signed_integer(row.count_diff),
116
+ percent(row.count_change_percent),
117
+ Formatting.fixed(row.before_duration_ms),
118
+ Formatting.fixed(row.after_duration_ms),
119
+ Formatting.signed_fixed(row.duration_diff_ms),
120
+ percent(row.duration_change_percent)
121
+ ]
111
122
  end
112
123
 
113
- def signed_integer(value)
114
- format("%+d", value)
124
+ # Reuse Row so the total's diff/percent math matches every other line.
125
+ def totals_row(rows)
126
+ FactoryComparison::Row.new(
127
+ label: "TOTAL",
128
+ before_count: rows.sum(&:before_count),
129
+ after_count: rows.sum(&:after_count),
130
+ before_duration_ms: rows.sum(&:before_duration_ms),
131
+ after_duration_ms: rows.sum(&:after_duration_ms)
132
+ )
115
133
  end
116
134
 
117
- def decimal(value)
118
- format("%.1f", value)
135
+ def separator(widths)
136
+ widths.map { |width| "-" * width }.join("-+-")
119
137
  end
120
138
 
121
- def signed_decimal(value)
122
- format("%+.1f", value)
139
+ def format_row(columns, widths)
140
+ columns.each_with_index.map do |value, index|
141
+ index.zero? ? value.ljust(widths[index]) : value.rjust(widths[index])
142
+ end.join(" | ")
123
143
  end
124
144
 
145
+ # "-" marks a missing baseline (before count/duration was zero), which is a
146
+ # table rule rather than number formatting, so it stays here.
125
147
  def percent(value)
126
- value ? format("%+.1f%%", value) : "-"
148
+ value ? Formatting.signed_percent(value) : "-"
127
149
  end
128
150
  end
129
151
  end
@@ -1,16 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "formatting"
4
+
3
5
  module RSpecTelemetry
4
6
  class ConsoleReport
5
7
  module Helpers
6
8
  module_function
7
9
 
8
- def fmt(ms)
9
- ms = ms.to_f
10
- ms >= 1000 ? format("%.2fs", ms / 1000.0) : format("%.1fms", ms)
11
- end
10
+ def fmt(ms) = Formatting.duration(ms)
12
11
 
13
- def pct(ratio) = format("%.1f%%", ratio * 100)
12
+ def pct(ratio) = Formatting.percent(ratio * 100)
14
13
 
15
14
  def truncate(str, len) = str.length > len ? "#{str[0, len - 1]}…" : str
16
15
 
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "ndjson"
4
+ require_relative "factory_aggregation"
4
5
 
5
6
  module RSpecTelemetry
6
7
  class FactoryComparison
7
- FactoryStat = Struct.new(:count, :duration_ms)
8
8
  Row = Struct.new(
9
- :factory,
9
+ :label,
10
10
  :before_count,
11
11
  :after_count,
12
12
  :before_duration_ms,
@@ -38,47 +38,75 @@ module RSpecTelemetry
38
38
  end
39
39
  end
40
40
 
41
- attr_reader :before_path, :after_path, :all_depths
41
+ attr_reader :before_path, :after_path, :all_depths, :by_factory
42
42
 
43
- def initialize(before_path, after_path, all_depths: false)
43
+ def initialize(before_path, after_path, all_depths: false, by_factory: false)
44
44
  @before_path = before_path
45
45
  @after_path = after_path
46
46
  @all_depths = all_depths
47
+ @by_factory = by_factory
48
+ end
49
+
50
+ def duration_label
51
+ all_depths ? "Self(ms)" : "Total(ms)"
47
52
  end
48
53
 
49
54
  def rows
50
55
  before = aggregate(before_path)
51
56
  after = aggregate(after_path)
52
57
 
53
- (before.keys | after.keys).sort.map do |factory|
54
- before_stat = before.fetch(factory, empty_stat)
55
- after_stat = after.fetch(factory, empty_stat)
58
+ (before.keys | after.keys).sort.map do |key|
59
+ before_stat = before[key]
60
+ after_stat = after[key]
56
61
 
57
62
  Row.new(
58
- factory: factory,
59
- before_count: before_stat.count,
60
- after_count: after_stat.count,
61
- before_duration_ms: before_stat.duration_ms,
62
- after_duration_ms: after_stat.duration_ms
63
+ label: key,
64
+ before_count: before_stat&.count || 0,
65
+ after_count: after_stat&.count || 0,
66
+ before_duration_ms: duration_for(before_stat),
67
+ after_duration_ms: duration_for(after_stat)
63
68
  )
64
69
  end
65
70
  end
66
71
 
67
72
  private
68
73
 
74
+ # Reuse the shared accumulator so counts, durations, and the factory:strategy
75
+ # granularity stay identical to the live summary, CLI report, and viewer.
76
+ # create and build are kept as separate keys (e.g. "user:create") unless
77
+ # by_factory rolls every strategy up under the bare factory name.
69
78
  def aggregate(path)
70
- stats = Hash.new { |hash, factory| hash[factory] = FactoryStat.new(0, 0.0) }
79
+ acc = FactoryAggregation::Accumulator.new
71
80
 
72
81
  File.foreach(path) do |line|
73
82
  event = Ndjson.parse(line)
74
83
  next unless factory_event?(event)
75
84
 
76
- stat = stats[event["factory"].to_s]
77
- stat.count += 1
78
- stat.duration_ms += duration_ms(event)
85
+ acc.add(
86
+ factory: event["factory"],
87
+ strategy: event["strategy"],
88
+ duration_ms: event["duration_ms"],
89
+ self_duration_ms: event["self_duration_ms"]
90
+ )
79
91
  end
80
92
 
81
- stats
93
+ stats = by_factory ? merge_by_factory(acc.stats) : acc.stats
94
+ stats.to_h { |stat| [stat.key, stat] }
95
+ end
96
+
97
+ # Collapse create/build/etc. into a single row keyed by the factory name.
98
+ def merge_by_factory(stats)
99
+ stats.group_by(&:factory).map do |factory, group|
100
+ FactoryAggregation::Stat.new(
101
+ key: factory,
102
+ factory: factory,
103
+ strategy: nil,
104
+ count: group.sum(&:count),
105
+ total_ms: group.sum(&:total_ms),
106
+ self_total_ms: group.sum(&:self_total_ms),
107
+ max_ms: group.map(&:max_ms).max
108
+ )
109
+ end
82
110
  end
83
111
 
84
112
  def factory_event?(event)
@@ -89,13 +117,12 @@ module RSpecTelemetry
89
117
  event["depth"].to_i.zero?
90
118
  end
91
119
 
92
- def duration_ms(event)
93
- key = all_depths ? "self_duration_ms" : "duration_ms"
94
- event[key].to_f
95
- end
120
+ # Default mode compares inclusive time on root events; --all-depths compares
121
+ # self time so nested children are not double-counted.
122
+ def duration_for(stat)
123
+ return 0.0 unless stat
96
124
 
97
- def empty_stat
98
- FactoryStat.new(0, 0.0)
125
+ all_depths ? stat.self_total_ms : stat.total_ms
99
126
  end
100
127
  end
101
128
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpecTelemetry
4
+ # Single source of truth for how telemetry numbers are rendered, so the decimal
5
+ # places, sign convention, and ms/s threshold stay consistent across the console
6
+ # report, the comparison table, and the live summary.
7
+ module Formatting
8
+ module_function
9
+
10
+ # Human-friendly duration: seconds past 1000ms, otherwise milliseconds.
11
+ def duration(ms)
12
+ ms = ms.to_f
13
+ ms >= 1000 ? format("%.2fs", ms / 1000.0) : format("%.1fms", ms)
14
+ end
15
+
16
+ def fixed(value) = format("%.1f", value.to_f)
17
+
18
+ def signed_fixed(value) = format("%+.1f", value.to_f)
19
+
20
+ def signed_integer(value) = format("%+d", value.to_i)
21
+
22
+ def percent(value) = format("%.1f%%", value.to_f)
23
+
24
+ def signed_percent(value) = format("%+.1f%%", value.to_f)
25
+ end
26
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "time"
4
4
 
5
+ require_relative "summary"
6
+
5
7
  module RSpecTelemetry
6
8
  class Recorder
7
9
  # FactoryBot notifications read this to attach themselves to the active example.
@@ -43,7 +45,6 @@ module RSpecTelemetry
43
45
  def finish
44
46
  return unless @started
45
47
 
46
- SummaryPrinter.print(@summary, @config) if @config.print_summary
47
48
  @writer.close
48
49
  @started = false
49
50
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "factory_aggregation"
4
+ require_relative "formatting"
4
5
 
5
6
  module RSpecTelemetry
6
7
  class Summary
@@ -102,10 +103,10 @@ module RSpecTelemetry
102
103
  tops.each_with_index do |f, i|
103
104
  lines << "#{i + 1}. #{f.key}"
104
105
  lines << " count: #{f.count}"
105
- lines << " self_total: #{round(f.self_total_ms)}ms"
106
- lines << " total: #{round(f.total_ms)}ms"
107
- lines << " avg: #{round(f.avg_ms)}ms"
108
- lines << " max: #{round(f.max_ms)}ms"
106
+ lines << " self_total: #{Formatting.fixed(f.self_total_ms)}ms"
107
+ lines << " total: #{Formatting.fixed(f.total_ms)}ms"
108
+ lines << " avg: #{Formatting.fixed(f.avg_ms)}ms"
109
+ lines << " max: #{Formatting.fixed(f.max_ms)}ms"
109
110
  lines << ""
110
111
  end
111
112
 
@@ -119,16 +120,12 @@ module RSpecTelemetry
119
120
  lines = ["Slow examples:", ""]
120
121
  slow.each_with_index do |e, i|
121
122
  lines << "#{i + 1}. #{e.example_id}"
122
- lines << " duration: #{round(e.duration_ms)}ms"
123
- lines << " factory_bot_total: #{round(e.factory_bot_total_ms)}ms (#{e.factory_bot_count} calls)"
123
+ lines << " duration: #{Formatting.fixed(e.duration_ms)}ms"
124
+ lines << " factory_bot_total: #{Formatting.fixed(e.factory_bot_total_ms)}ms (#{e.factory_bot_count} calls)"
124
125
  lines << ""
125
126
  end
126
127
 
127
128
  lines
128
129
  end
129
-
130
- def round(value)
131
- value.to_f.round(1)
132
- end
133
130
  end
134
131
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RSpecTelemetry
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -32,7 +32,13 @@ module RSpecTelemetry
32
32
  end
33
33
 
34
34
  def finish!
35
- @recorder&.finish
35
+ active = @recorder
36
+ if active&.started?
37
+ # Printing the end-of-run summary is a reporting concern owned by the
38
+ # lifecycle, not by the Recorder (which only records events).
39
+ SummaryPrinter.print(active.summary, config)
40
+ end
41
+ active&.finish
36
42
  unsubscribe!
37
43
  end
38
44
 
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - takahashimm
@@ -63,6 +63,7 @@ files:
63
63
  - lib/rspec_telemetry/factory_aggregation.rb
64
64
  - lib/rspec_telemetry/factory_comparison.rb
65
65
  - lib/rspec_telemetry/formatter.rb
66
+ - lib/rspec_telemetry/formatting.rb
66
67
  - lib/rspec_telemetry/ndjson.rb
67
68
  - lib/rspec_telemetry/recorder.rb
68
69
  - lib/rspec_telemetry/subscribers/factory_bot.rb