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 +4 -4
- data/.github/workflows/main.yml +10 -2
- data/.gitignore +2 -1
- data/README.md +59 -0
- data/Rakefile +9 -1
- data/changes.md +6 -0
- data/lib/coverband/collectors/delta.rb +21 -7
- data/lib/coverband/collectors/query_burst_tracker.rb +268 -0
- data/lib/coverband/configuration.rb +11 -0
- data/lib/coverband/reporters/web.rb +25 -1
- data/lib/coverband/utils/html_formatter.rb +3 -1
- data/lib/coverband/version.rb +1 -1
- data/lib/coverband.rb +3 -2
- data/public/application.css +27 -0
- data/public/application.js +246 -0
- data/public/dead_code_session.js +173 -0
- data/test/benchmarks/benchmark.rake +18 -10
- data/test/coverband/benchmarks/benchmark_memory_leak_check_test.rb +43 -0
- data/test/coverband/collectors/delta_test.rb +17 -0
- data/test/coverband/collectors/query_burst_tracker_test.rb +77 -0
- data/test/coverband/reporters/web_test.rb +67 -0
- data/test/coverband/track_key_test.rb +9 -0
- data/test/forked/rails_route_tracker_stack_test.rb +1 -1
- data/test/forked/rails_view_tracker_stack_test.rb +1 -1
- data/test/javascript/dead_code_session.test.mjs +123 -0
- data/views/abstract_tracker.erb +49 -2
- data/views/layout.erb +1 -0
- data/views/nav.erb +5 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 20188ba8fca24027b2a7bd367ac88586b7dace04a6eadfd9714bdf509849683e
|
|
4
|
+
data.tar.gz: c2db00de6ffaa32256d083c7d51290a5936f5e2ee46c774eed901f91fa0ba545
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 22b5b1281136a3ed3172383d84526f1659568e6b204cc38b636d929c76b8ec84c9b70d20e61a54484cf97d8604e268de9da87cc596bbff2cbb81752c1078ca8b
|
|
7
|
+
data.tar.gz: aa55b358d2a3a196fd5de29f850ad41a877274649ccf377395a114c6e940bc926424fccbe6a3e7642225eaa81432f73094443b5874c8e79ac75cccb39a6c3422
|
data/.github/workflows/main.yml
CHANGED
|
@@ -39,7 +39,7 @@ jobs:
|
|
|
39
39
|
--health-timeout 5s
|
|
40
40
|
--health-retries 5
|
|
41
41
|
steps:
|
|
42
|
-
- uses: actions/checkout@
|
|
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@
|
|
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
data/README.md
CHANGED
|
@@ -309,6 +309,55 @@ When enabled, Coverband subscribes to the `render.view_component` notification a
|
|
|
309
309
|
|
|
310
310
|

|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
data/lib/coverband/version.rb
CHANGED
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(", ")}"
|
data/public/application.css
CHANGED
|
@@ -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; }
|