coverband 6.2.1 → 6.2.2

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: c47e38934426a369c81dd7d08e5474ad0d8a784b541975c1aa7ee50fc9e2d1ce
4
- data.tar.gz: 346c8263e9c961631bcf4af3019d8473177088c5b9ae3cb3844dc7d86bcd7497
3
+ metadata.gz: 20188ba8fca24027b2a7bd367ac88586b7dace04a6eadfd9714bdf509849683e
4
+ data.tar.gz: c2db00de6ffaa32256d083c7d51290a5936f5e2ee46c774eed901f91fa0ba545
5
5
  SHA512:
6
- metadata.gz: 907a280ce513b23922b81c44674c135ab067e629ae9f8aaec6eb6a667946a52a85fd1e7a0fe17ee9b24c0a6b81b8b22bcb1e1c53e7ce75ccc7a9c3507b6dd183
7
- data.tar.gz: 60198f0536094c5505ea532b238d9d38359f936edb6ea21854c946d3a5f63e7e02b66f1240596b787cad898546a3d48d41595d5d4cab28bb09face2a4f405499
6
+ metadata.gz: 22b5b1281136a3ed3172383d84526f1659568e6b204cc38b636d929c76b8ec84c9b70d20e61a54484cf97d8604e268de9da87cc596bbff2cbb81752c1078ca8b
7
+ data.tar.gz: aa55b358d2a3a196fd5de29f850ad41a877274649ccf377395a114c6e940bc926424fccbe6a3e7642225eaa81432f73094443b5874c8e79ac75cccb39a6c3422
@@ -39,7 +39,7 @@ jobs:
39
39
  --health-timeout 5s
40
40
  --health-retries 5
41
41
  steps:
42
- - uses: actions/checkout@v6
42
+ - uses: actions/checkout@v7
43
43
  - uses: ruby/setup-ruby@v1
44
44
  with:
45
45
  ruby-version: ${{ matrix.ruby }}
@@ -49,9 +49,17 @@ jobs:
49
49
  starndardrb:
50
50
  runs-on: ubuntu-latest
51
51
  steps:
52
- - uses: actions/checkout@v6
52
+ - uses: actions/checkout@v7
53
53
  - uses: ruby/setup-ruby@v1
54
54
  with:
55
55
  ruby-version: 3.1
56
56
  bundler-cache: true # runs 'bundle install' and caches installed gems automatically
57
57
  - run: bundle exec standardrb --format github
58
+ javascript:
59
+ runs-on: ubuntu-latest
60
+ steps:
61
+ - uses: actions/checkout@v7
62
+ - uses: actions/setup-node@v4
63
+ with:
64
+ node-version: "20"
65
+ - run: node --test test/javascript/*.test.mjs
data/.gitignore CHANGED
@@ -26,4 +26,5 @@ log/
26
26
  test/unique_files
27
27
  test/rails7_dummy/tmp
28
28
  CLAUDE.md
29
- .claude/
29
+ .claude/
30
+ scratch/
data/README.md CHANGED
@@ -309,6 +309,55 @@ When enabled, Coverband subscribes to the `render.view_component` notification a
309
309
 
310
310
  ![image](https://raw.github.com/danmayer/coverband/main/docs/coverband_view_tracker.png)
311
311
 
312
+ ### Query Burst Tracking
313
+
314
+ Coverband includes an optional Query Burst Tracker to monitor SQL activity per request and per background job in production. It helps identify potential N+1 query issues and bursty database behavior.
315
+
316
+ Enable it in your Coverband configuration:
317
+
318
+ ```ruby
319
+ Coverband.configure do |config|
320
+ config.track_query_bursts = true
321
+
322
+ # Thresholds used to flag heavy requests/jobs
323
+ config.query_burst_query_count_threshold = 30
324
+ config.query_burst_sql_time_threshold_ms = 100.0
325
+ end
326
+ ```
327
+
328
+ When enabled, Coverband records stats by execution context (for example controller/action and ActiveJob class) and exposes them in the Query Burst Tracker tab in the web UI.
329
+
330
+ #### Threshold Settings
331
+
332
+ - `query_burst_query_count_threshold`:
333
+ - Max SQL query count allowed for a single tracked execution (one request or one job run).
334
+ - If query count is greater than or equal to this value, it is counted as a threshold hit.
335
+ - `query_burst_sql_time_threshold_ms`:
336
+ - Max total SQL time (in milliseconds) allowed for a single tracked execution.
337
+ - If SQL time is greater than or equal to this value, it is counted as a threshold hit.
338
+
339
+ Threshold logic is OR-based: Coverband records a threshold hit if either threshold is exceeded.
340
+
341
+ #### What You See in the UI
342
+
343
+ For each tracked key, the Query Burst Tracker reports:
344
+
345
+ - Requests: Number of tracked executions
346
+ - Total SQL Calls: Sum of all SQL calls across executions
347
+ - Total SQL ms: Sum of SQL time across executions
348
+ - Max SQL Calls: Highest query count seen in one execution
349
+ - Max SQL ms: Highest SQL time seen in one execution
350
+ - Threshold Hits: Number of executions that exceeded either threshold
351
+
352
+ #### How To Tune Thresholds
353
+
354
+ - Start with defaults (`30` queries, `100.0` ms SQL time).
355
+ - Lower thresholds to detect smaller regressions sooner.
356
+ - Raise thresholds if you see too much noise from expected heavy endpoints/jobs.
357
+ - Tune by endpoint/job behavior: latency-sensitive paths often need stricter limits than batch work.
358
+
359
+ Tip: use this tracker in production-like traffic, where query patterns are most realistic.
360
+
312
361
  ### Hiding settings
313
362
 
314
363
  Coverband provides a view of all of its current settings. Sometimes you might want to hide this view,
@@ -317,6 +366,16 @@ You can disable the settings view like so:
317
366
 
318
367
  `config.hide_settings = true`
319
368
 
369
+ ### Dead-code review session
370
+
371
+ The web report includes a client-side "dead-code session" toolbar to help drive code-removal work:
372
+
373
+ - Hide files or whole folders from the report via the ✕ icon that appears when hovering path segments, or hide everything with runtime % > 0 with one toggle.
374
+ - Mark files or folders for deletion via the 🗑 icon (with an optional comment). Marked items disappear from the view and are listed in the "Marked for deletion" modal.
375
+ - Export the marked list as a CSV (`path,comment,marked_at`) to feed a code-removal PR process.
376
+
377
+ Session state lives in your browser's localStorage: "Reset session" clears the hidden set; "Start over" (in the modal) clears the marked list.
378
+
320
379
  ### Fixing Coverage Only Shows Loading Hits
321
380
 
322
381
  If all your coverage is being counted as loading or eager_loading coverage, and nothing is showing as runtime Coverage the initialization hook failed for some reason. The most likely reason for this issue is manually calling `eager_load!` on some Plugin/Gem. If you or a plugin is altering the Rails initialization process, you can manually flip Coverband to runtime coverage by calling these two lines, in an `after_initialize` block, in `application.rb`.
data/Rakefile CHANGED
@@ -8,7 +8,7 @@ RuboCop::RakeTask.new
8
8
 
9
9
  task default: %i[test]
10
10
 
11
- task "test:all": %i[test forked_tests benchmarks:memory benchmarks]
11
+ task "test:all": %i[test test:javascript forked_tests benchmarks:memory benchmarks]
12
12
 
13
13
  task :test
14
14
  require "rake/testtask"
@@ -21,6 +21,14 @@ Rake::TestTask.new(:test) do |test|
21
21
  test.verbose = true
22
22
  end
23
23
 
24
+ desc "run the web report's javascript unit tests (node --test)"
25
+ task :"test:javascript" do
26
+ # Pass expanded file paths rather than the bare directory: node's
27
+ # directory-argument handling for --test is inconsistent across versions
28
+ # (Node 22 fails to find test/javascript/ as a directory in some CI images).
29
+ sh "node --test #{FileList["test/javascript/*.test.mjs"].join(" ")}"
30
+ end
31
+
24
32
  Rake::TestTask.new(:forked_tests) do |test|
25
33
  if RUBY_PLATFORM == "java"
26
34
  puts "forked tests not supported on JRuby"
data/changes.md CHANGED
@@ -1,3 +1,9 @@
1
+ ### 6.2.2
2
+
3
+ * Feature: Added a dead-code review session to the web report — hide files/folders by hovering any path segment, mark files for deletion with an optional comment, and export marked entries to CSV to drive an out-of-band code-removal process; session state lives in browser localStorage; thanks @obie (#647, #649)
4
+ * Feature: Added `Coverband::Collectors::QueryBurstTracker` to detect N+1/burst SQL patterns per request or job, with configurable query-count and SQL-time thresholds and a new Query Burst Tracker tab in the web UI (#644)
5
+ * Fix: Scoped the `ruby-head` memory-leak benchmark check to only fail on Coverband's own retained objects, fixing CI false positives caused by stdlib/gem string-deduplication artifacts on newer Ruby (#648)
6
+
1
7
  ### 6.2.1
2
8
 
3
9
  * Fix: Search now includes full filenames hidden by truncation in the web UI; users can search for complete file paths even when displaying truncated versions (#569)
@@ -59,10 +59,12 @@ module Coverband
59
59
 
60
60
  # This handles Coverage branch support, setup by default in
61
61
  # simplecov 0.18.x
62
- arr_line_counts = line_counts.is_a?(Hash) ? line_counts[:lines] : line_counts
62
+ arr_line_counts = extract_line_counts(file, line_counts)
63
+ next unless arr_line_counts
64
+
63
65
  new_results[file] = if @@previous_coverage && @@previous_coverage[file]
64
- prev_line_counts = @@previous_coverage[file].is_a?(Hash) ? @@previous_coverage[file][:lines] : @@previous_coverage[file]
65
- array_diff(arr_line_counts, prev_line_counts)
66
+ prev_line_counts = extract_line_counts(file, @@previous_coverage[file])
67
+ prev_line_counts ? array_diff(arr_line_counts, prev_line_counts) : arr_line_counts
66
68
  else
67
69
  arr_line_counts
68
70
  end
@@ -87,13 +89,25 @@ module Coverband
87
89
  next unless file.start_with?(@@project_directory) &&
88
90
  @@ignore_patterns.none? { |pattern| file.match(pattern) }
89
91
 
90
- @@stubs[file] ||= ::Coverage.line_stub(file)
91
- transformed_line_counts = coverage[:oneshot_lines].each_with_object(@@stubs[file].dup) { |line_number, line_counts|
92
- line_counts[line_number - 1] = 1
93
- }
92
+ transformed_line_counts = oneshot_lines_to_line_counts(file, coverage[:oneshot_lines])
94
93
  new_results[file] = transformed_line_counts
95
94
  end
96
95
  end
96
+
97
+ def extract_line_counts(file, coverage_data)
98
+ return coverage_data unless coverage_data.is_a?(Hash)
99
+
100
+ coverage_data[:lines] || oneshot_lines_to_line_counts(file, coverage_data[:oneshot_lines])
101
+ end
102
+
103
+ def oneshot_lines_to_line_counts(file, oneshot_lines)
104
+ return nil unless oneshot_lines
105
+
106
+ @@stubs[file] ||= ::Coverage.line_stub(file)
107
+ oneshot_lines.each_with_object(@@stubs[file].dup) do |line_number, line_counts|
108
+ line_counts[line_number - 1] = 1
109
+ end
110
+ end
97
111
  end
98
112
  end
99
113
  end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "set"
5
+ require "singleton"
6
+
7
+ module Coverband
8
+ module Collectors
9
+ # Lightweight SQL burst tracker for production runtime behavior.
10
+ # Aggregates per controller action / job class and persists compact stats.
11
+ class QueryBurstTracker < AbstractTracker
12
+ REPORT_ROUTE = "query_bursts_tracker"
13
+ TITLE = "Query Bursts"
14
+ CONTEXT_STACK_KEY = :coverband_query_burst_context_stack
15
+ IGNORED_SQL_NAMES = %w[SCHEMA CACHE TRANSACTION].freeze
16
+
17
+ def initialize(options = {})
18
+ super
19
+ @pending_stats = {}
20
+ end
21
+
22
+ def railtie!
23
+ ActiveSupport::Notifications.subscribe("start_processing.action_controller") do |_name, _start, _finish, _id, payload|
24
+ start_context(:controller, controller_key(payload))
25
+ end
26
+
27
+ ActiveSupport::Notifications.subscribe("process_action.action_controller") do |_name, _start, _finish, _id, payload|
28
+ finish_context(:controller, controller_key(payload))
29
+ end
30
+
31
+ ActiveSupport::Notifications.subscribe("perform_start.active_job") do |_name, _start, _finish, _id, payload|
32
+ start_context(:job, job_key(payload))
33
+ end
34
+
35
+ ActiveSupport::Notifications.subscribe("perform.active_job") do |_name, _start, _finish, _id, payload|
36
+ finish_context(:job, job_key(payload))
37
+ end
38
+
39
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |_name, start, finish, _id, payload|
40
+ next if ignore_sql_event?(payload)
41
+
42
+ record_sql_event(duration_ms(start, finish))
43
+ end
44
+ end
45
+
46
+ def track_key(payload)
47
+ key = payload[:key] || payload["key"]
48
+ return unless key
49
+
50
+ queries = (payload[:queries] || payload["queries"] || 0).to_i
51
+ sql_time_ms = (payload[:sql_time_ms] || payload["sql_time_ms"] || 0.0).to_f
52
+ record_observation(key, queries, sql_time_ms)
53
+ end
54
+
55
+ def used_keys
56
+ return {} unless redis_store
57
+
58
+ stats_hash = redis_store.hgetall(tracker_key)
59
+ stats_hash.each_with_object({}) do |(key, stats_json), used|
60
+ stats = parse_stats(stats_json)
61
+ used[key] = stats["last_seen"].to_i
62
+ end
63
+ end
64
+
65
+ def used_key_stats
66
+ return {} unless redis_store
67
+
68
+ stats_hash = redis_store.hgetall(tracker_key)
69
+ stats_hash
70
+ .transform_values { |stats_json| parse_stats(stats_json) }
71
+ .sort_by { |key, stats| [-stats["threshold_hits"].to_i, -stats["total_sql_time_ms"].to_f, key] }
72
+ .to_h
73
+ end
74
+
75
+ def unused_keys(_used_keys = nil)
76
+ []
77
+ end
78
+
79
+ def all_keys
80
+ []
81
+ end
82
+
83
+ def as_json
84
+ {
85
+ tracking_since: tracking_since,
86
+ thresholds: {
87
+ query_count: query_count_threshold,
88
+ sql_time_ms: sql_time_threshold_ms
89
+ },
90
+ used_keys: used_key_stats
91
+ }.to_json
92
+ end
93
+
94
+ def save_report
95
+ return unless redis_store
96
+ return if @pending_stats.empty?
97
+
98
+ redis_store.set(tracker_time_key, Time.now.to_i) unless @one_time_timestamp || tracker_time_key_exists?
99
+ @one_time_timestamp = true
100
+
101
+ existing_stats = redis_store.hgetall(tracker_key)
102
+ merged = @pending_stats.each_with_object({}) do |(key, pending), h|
103
+ existing = parse_stats(existing_stats[key])
104
+ h[key] = merge_stats(existing, pending).to_json
105
+ end
106
+
107
+ redis_store.hset(tracker_key, merged) if merged.any?
108
+ @pending_stats.clear
109
+ rescue => e
110
+ logger&.error "Coverband: #{self.class.name} failed to store, error #{e.class.name} info #{e.message}"
111
+ end
112
+
113
+ private
114
+
115
+ def start_context(type, key)
116
+ return unless key
117
+
118
+ context_stack << {
119
+ type: type,
120
+ key: key,
121
+ queries: 0,
122
+ sql_time_ms: 0.0
123
+ }
124
+ end
125
+
126
+ def finish_context(type, key)
127
+ context = pop_context(type, key)
128
+ return unless context
129
+
130
+ record_observation(context[:key], context[:queries], context[:sql_time_ms])
131
+ end
132
+
133
+ def pop_context(type, key)
134
+ index = context_stack.rindex { |context| context[:type] == type && context[:key] == key }
135
+ return unless index
136
+
137
+ context_stack.delete_at(index)
138
+ end
139
+
140
+ def record_sql_event(ms)
141
+ return if context_stack.empty?
142
+
143
+ context = context_stack.last
144
+ context[:queries] += 1
145
+ context[:sql_time_ms] += ms
146
+ end
147
+
148
+ def record_observation(key, query_count, sql_time_ms)
149
+ key = key.to_s
150
+ stats = @pending_stats[key] || default_stats
151
+
152
+ stats["requests"] += 1
153
+ stats["total_queries"] += query_count
154
+ stats["total_sql_time_ms"] += sql_time_ms
155
+ stats["max_queries"] = [stats["max_queries"], query_count].max
156
+ stats["max_sql_time_ms"] = [stats["max_sql_time_ms"], sql_time_ms].max
157
+
158
+ now = Time.now.to_i
159
+ stats["last_seen"] = now
160
+ stats["last_event"] = {
161
+ "queries" => query_count,
162
+ "sql_time_ms" => sql_time_ms.round(3),
163
+ "at" => now
164
+ }
165
+ stats["threshold_hits"] += 1 if threshold_hit?(query_count, sql_time_ms)
166
+
167
+ @pending_stats[key] = stats
168
+ @logged_keys << key
169
+ end
170
+
171
+ def threshold_hit?(query_count, sql_time_ms)
172
+ query_count >= query_count_threshold || sql_time_ms >= sql_time_threshold_ms
173
+ end
174
+
175
+ def default_stats
176
+ {
177
+ "requests" => 0,
178
+ "total_queries" => 0,
179
+ "total_sql_time_ms" => 0.0,
180
+ "max_queries" => 0,
181
+ "max_sql_time_ms" => 0.0,
182
+ "threshold_hits" => 0,
183
+ "last_seen" => nil,
184
+ "last_event" => nil
185
+ }
186
+ end
187
+
188
+ def merge_stats(existing, pending)
189
+ {
190
+ "requests" => existing["requests"].to_i + pending["requests"].to_i,
191
+ "total_queries" => existing["total_queries"].to_i + pending["total_queries"].to_i,
192
+ "total_sql_time_ms" => existing["total_sql_time_ms"].to_f + pending["total_sql_time_ms"].to_f,
193
+ "max_queries" => [existing["max_queries"].to_i, pending["max_queries"].to_i].max,
194
+ "max_sql_time_ms" => [existing["max_sql_time_ms"].to_f, pending["max_sql_time_ms"].to_f].max,
195
+ "threshold_hits" => existing["threshold_hits"].to_i + pending["threshold_hits"].to_i,
196
+ "last_seen" => [existing["last_seen"].to_i, pending["last_seen"].to_i].max,
197
+ "last_event" => latest_event(existing["last_event"], pending["last_event"])
198
+ }
199
+ end
200
+
201
+ def latest_event(existing_event, pending_event)
202
+ return pending_event unless existing_event
203
+ return existing_event unless pending_event
204
+
205
+ if existing_event["at"].to_i >= pending_event["at"].to_i
206
+ existing_event
207
+ else
208
+ pending_event
209
+ end
210
+ end
211
+
212
+ def parse_stats(stats_json)
213
+ return default_stats unless stats_json
214
+
215
+ parsed = JSON.parse(stats_json)
216
+ default_stats.merge(parsed)
217
+ rescue JSON::ParserError
218
+ default_stats
219
+ end
220
+
221
+ def context_stack
222
+ Thread.current[CONTEXT_STACK_KEY] ||= []
223
+ end
224
+
225
+ def ignore_sql_event?(payload)
226
+ return true if payload[:cached]
227
+
228
+ name = payload[:name].to_s
229
+ IGNORED_SQL_NAMES.include?(name)
230
+ end
231
+
232
+ def duration_ms(start, finish)
233
+ (finish - start) * 1000.0
234
+ end
235
+
236
+ def controller_key(payload)
237
+ controller = payload[:controller] || payload.dig(:params, "controller")
238
+ action = payload[:action]
239
+ return unless controller && action
240
+
241
+ "controller:#{controller}##{action}"
242
+ end
243
+
244
+ def job_key(payload)
245
+ job = payload[:job]
246
+ return unless job
247
+
248
+ queue_name = if job.respond_to?(:queue_name)
249
+ job.queue_name
250
+ end
251
+ queue_suffix = queue_name ? " queue:#{queue_name}" : ""
252
+ "job:#{job.class.name}#{queue_suffix}"
253
+ end
254
+
255
+ def query_count_threshold
256
+ Coverband.configuration.query_burst_query_count_threshold.to_i
257
+ end
258
+
259
+ def sql_time_threshold_ms
260
+ Coverband.configuration.query_burst_sql_time_threshold_ms.to_f
261
+ end
262
+
263
+ def concrete_target
264
+ []
265
+ end
266
+ end
267
+ end
268
+ end
@@ -14,6 +14,8 @@ module Coverband
14
14
  :view_tracker, :defer_eager_loading_data,
15
15
  :track_routes, :track_redirect_routes, :route_tracker,
16
16
  :track_translations, :translations_tracker,
17
+ :track_query_bursts, :query_burst_tracker,
18
+ :query_burst_query_count_threshold, :query_burst_sql_time_threshold_ms,
17
19
  :trackers, :csp_policy, :hide_settings,
18
20
  :mcp_enabled
19
21
 
@@ -81,6 +83,10 @@ module Coverband
81
83
  @route_tracker = nil
82
84
  @track_translations = false
83
85
  @translations_tracker = nil
86
+ @track_query_bursts = false
87
+ @query_burst_tracker = nil
88
+ @query_burst_query_count_threshold = 30
89
+ @query_burst_sql_time_threshold_ms = 100.0
84
90
  @web_debug = false
85
91
  @report_on_exit = true
86
92
  @use_oneshot_lines_coverage = ENV["ONESHOT"] || false
@@ -140,6 +146,11 @@ module Coverband
140
146
  end
141
147
  trackers << Coverband.configuration.view_tracker
142
148
  end
149
+
150
+ if Coverband.configuration.track_query_bursts
151
+ Coverband.configuration.query_burst_tracker = Coverband::Collectors::QueryBurstTracker.new
152
+ trackers << Coverband.configuration.query_burst_tracker
153
+ end
143
154
  trackers.each { |tracker| tracker.railtie! }
144
155
  rescue Redis::CannotConnectError => e
145
156
  Coverband.configuration.logger.info "Redis is not available (#{e}), Coverband not configured"
@@ -153,10 +153,13 @@ module Coverband
153
153
  def display_abstract_tracker(tracker)
154
154
  notice = "<strong>Notice:</strong> #{Rack::Utils.escape_html(request.params["notice"])}<br/>"
155
155
  notice = request.params["notice"] ? notice : ""
156
+ used_keys_sort = tracker_used_keys_sort_mode(tracker)
156
157
  options = {
157
158
  tracker: tracker,
158
159
  notice: notice,
159
- base_path: base_path
160
+ base_path: base_path,
161
+ used_keys_sort: used_keys_sort,
162
+ sorted_used_keys: sorted_tracker_used_keys(tracker, used_keys_sort)
160
163
  }
161
164
  Coverband::Utils::HTMLFormatter.new(nil, options).format_abstract_tracker!
162
165
  end
@@ -229,6 +232,27 @@ module Coverband
229
232
 
230
233
  private
231
234
 
235
+ def tracker_used_keys_sort_mode(tracker)
236
+ requested_sort = request.params["used_sort"]
237
+ return requested_sort if %w[alpha last_active].include?(requested_sort)
238
+
239
+ views_tracker?(tracker) ? "last_active" : "alpha"
240
+ end
241
+
242
+ def sorted_tracker_used_keys(tracker, used_keys_sort)
243
+ used_keys = tracker.used_keys
244
+ sorted_pairs = if used_keys_sort == "last_active"
245
+ used_keys.sort_by { |_key, time_at| -time_at.to_i }
246
+ else
247
+ used_keys.sort_by { |key, _time_at| key.to_s }
248
+ end
249
+ sorted_pairs.to_h
250
+ end
251
+
252
+ def views_tracker?(tracker)
253
+ tracker.route == Coverband::Collectors::ViewTracker::REPORT_ROUTE
254
+ end
255
+
232
256
  def coverband_headers(content_type: "text/html")
233
257
  web_headers = {
234
258
  "content-type" => content_type
@@ -13,13 +13,15 @@ require "time"
13
13
  module Coverband
14
14
  module Utils
15
15
  class HTMLFormatter
16
- attr_reader :notice, :base_path, :tracker, :page
16
+ attr_reader :notice, :base_path, :tracker, :page, :used_keys_sort, :sorted_used_keys
17
17
 
18
18
  def initialize(report, options = {})
19
19
  @notice = options.fetch(:notice, nil)
20
20
  @base_path = options.fetch(:base_path, "./")
21
21
  @tracker = options.fetch(:tracker, nil)
22
22
  @page = options.fetch(:page, nil)
23
+ @used_keys_sort = options.fetch(:used_keys_sort, nil)
24
+ @sorted_used_keys = options.fetch(:sorted_used_keys, nil)
23
25
  @coverage_result = Coverband::Utils::Results.new(report) if report
24
26
  end
25
27
 
@@ -5,5 +5,5 @@
5
5
  # use format "4.2.1.rc.1" ~> 4.2.1.rc to prerelease versions like v4.2.1.rc.2 and v4.2.1.rc.3
6
6
  ###
7
7
  module Coverband
8
- VERSION = "6.2.1"
8
+ VERSION = "6.2.2"
9
9
  end
data/lib/coverband.rb CHANGED
@@ -21,6 +21,7 @@ require "coverband/collectors/view_tracker"
21
21
  require "coverband/collectors/view_tracker_service"
22
22
  require "coverband/collectors/route_tracker"
23
23
  require "coverband/collectors/translation_tracker"
24
+ require "coverband/collectors/query_burst_tracker"
24
25
  require "coverband/reporters/base"
25
26
  require "coverband/reporters/console_report"
26
27
  require "coverband/integrations/background"
@@ -101,14 +102,14 @@ module Coverband
101
102
  end
102
103
 
103
104
  # Track a key with the specified tracker.
104
- # @param tracker_type [Symbol] The type of tracker to use (e.g., :view_tracker, :translations_tracker, :routes_tracker)
105
+ # @param tracker_type [Symbol] The type of tracker to use (e.g., :view_tracker, :translations_tracker, :routes_tracker, :query_burst_tracker)
105
106
  # @param key [String] The key to track
106
107
  # @return [Boolean] True if tracking was successful, false otherwise
107
108
  # @raise [ArgumentError] If the tracker_type is not supported
108
109
  def self.track_key(tracker_type, key)
109
110
  return false unless key
110
111
 
111
- supported_trackers = [:view_tracker, :translations_tracker, :routes_tracker]
112
+ supported_trackers = [:view_tracker, :translations_tracker, :routes_tracker, :query_burst_tracker]
112
113
 
113
114
  unless supported_trackers.include?(tracker_type)
114
115
  raise ArgumentError, "Unsupported tracker type: #{tracker_type}. Must be one of: #{supported_trackers.join(", ")}"
@@ -867,3 +867,30 @@ td:has(> span.green) {
867
867
  background-color: #fbf0c0; }
868
868
  .source_table .skipped:nth-child(even) {
869
869
  background-color: #fbffcf; }
870
+
871
+ /* Dead-code review session */
872
+ #dcs-toolbar { margin: 8px 0 12px 0; padding: 8px 10px; background: #f4f4f4; border: 1px solid #ddd; border-radius: 4px; font-size: 0.85em; }
873
+ #dcs-toolbar label { margin: 0 10px; cursor: pointer; }
874
+ #dcs-toolbar button { margin: 0 4px; cursor: pointer; }
875
+ .dcs-badge { background: #666; color: #fff; border-radius: 8px; padding: 1px 8px; margin: 0 6px; }
876
+ #dcs-chips { margin-top: 6px; display: none; }
877
+ .dcs-chip { display: inline-block; background: #e8e8e8; border: 1px solid #ccc; border-radius: 10px; padding: 1px 6px; margin: 2px; font-family: monospace; }
878
+ .dcs-chip-x { border: none; background: transparent; cursor: pointer; font-weight: bold; margin-left: 4px; }
879
+ .dcs-seg { position: relative; }
880
+ .dcs-actions { display: none; white-space: nowrap; }
881
+ .dcs-seg:hover > .dcs-actions { display: inline; }
882
+ .dcs-actions button { border: none; background: #ddd; border-radius: 3px; cursor: pointer; font-size: 0.85em; padding: 0 3px; margin-left: 2px; line-height: 1.2; }
883
+ .dcs-actions button:hover { background: #bbb; }
884
+ #dcs-mark-popup { position: absolute; z-index: 10000; background: #fff; border: 1px solid #999; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.3); padding: 10px; width: 320px; }
885
+ #dcs-mark-popup .dcs-popup-path { font-family: monospace; font-weight: bold; margin-bottom: 6px; word-break: break-all; }
886
+ #dcs-mark-popup textarea { width: 100%; box-sizing: border-box; }
887
+ #dcs-mark-popup .dcs-popup-actions { margin-top: 6px; text-align: right; }
888
+ #dcs-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 10001; }
889
+ #dcs-modal { background: #fff; border-radius: 6px; width: 80%; max-width: 900px; max-height: 80%; overflow: auto; margin: 5% auto; padding: 16px; }
890
+ #dcs-modal-head { display: flex; justify-content: space-between; margin-bottom: 10px; }
891
+ #dcs-modal-table { width: 100%; border-collapse: collapse; }
892
+ #dcs-modal-table th, #dcs-modal-table td { border-bottom: 1px solid #eee; padding: 4px 8px; text-align: left; }
893
+ #dcs-modal-foot { margin-top: 12px; text-align: right; }
894
+ #dcs-modal-foot button { margin-left: 8px; }
895
+ .dcs-mono { font-family: monospace; }
896
+ .dcs-comment-edit { width: 100%; box-sizing: border-box; }