coverband 6.2.0 → 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 +119 -2
- data/Rakefile +9 -1
- data/changes.md +16 -0
- data/lib/coverband/collectors/abstract_tracker.rb +17 -1
- data/lib/coverband/collectors/delta.rb +21 -7
- data/lib/coverband/collectors/query_burst_tracker.rb +268 -0
- data/lib/coverband/collectors/view_tracker.rb +11 -1
- data/lib/coverband/configuration.rb +14 -0
- data/lib/coverband/mcp/http_handler.rb +27 -26
- data/lib/coverband/reporters/web.rb +28 -2
- data/lib/coverband/utils/configuration_template.rb +4 -0
- data/lib/coverband/utils/file_list.rb +1 -0
- data/lib/coverband/utils/html_formatter.rb +7 -2
- data/lib/coverband/utils/tasks.rb +18 -0
- 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/benchmarks/benchmark_file_list_covered_lines.rb +54 -0
- 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/collectors/view_tracker_test.rb +27 -0
- data/test/coverband/configuration_test.rb +12 -0
- data/test/coverband/mcp/http_handler_test.rb +58 -46
- 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/integration/mcp_integration_test.rb +3 -3
- 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 +7 -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
|
@@ -45,6 +45,28 @@ Coverband stores coverage data in Redis. The Redis endpoint is looked for in thi
|
|
|
45
45
|
|
|
46
46
|
The redis store can also be explicitly defined within the `config/coverband.rb`. See [advanced config](#advanced-config).
|
|
47
47
|
|
|
48
|
+
### Redis with TLS
|
|
49
|
+
|
|
50
|
+
For Redis servers that require TLS (such as AWS serverless ElastiCache), use the `rediss://` URL scheme instead of `redis://`. The Redis gem automatically enables TLS when it detects this scheme:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# Example with AWS serverless ElastiCache
|
|
54
|
+
REDIS_URL=rediss://my-elasticache.abcdef.cache.amazonaws.com:6379
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or in `config/coverband.rb`:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
config.store = Coverband::Adapters::RedisStore.new(
|
|
61
|
+
Redis.new(url: "rediss://my-elasticache-endpoint:6379")
|
|
62
|
+
)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The `rediss://` scheme works with:
|
|
66
|
+
- AWS ElastiCache (serverless or provisioned with required TLS)
|
|
67
|
+
- Any self-hosted Redis with TLS enabled
|
|
68
|
+
- Other managed Redis services that require TLS
|
|
69
|
+
|
|
48
70
|
## Gem Installation
|
|
49
71
|
|
|
50
72
|
Add this line to your application's `Gemfile`, remember to `bundle install` after updating:
|
|
@@ -271,8 +293,71 @@ This feature is enabled by default. To stop this feature, disable the feature in
|
|
|
271
293
|
|
|
272
294
|
`config.track_views = false`
|
|
273
295
|
|
|
296
|
+
#### ViewComponent Support
|
|
297
|
+
|
|
298
|
+
Coverband can also track [ViewComponent](https://viewcomponent.org/) renders. This requires:
|
|
299
|
+
|
|
300
|
+
- ViewComponent **>= 4.6.0**
|
|
301
|
+
- Instrumentation enabled in your app config:
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
# config/application.rb (or environment-specific config)
|
|
305
|
+
config.view_component.instrumentation_enabled = true
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
When enabled, Coverband subscribes to the `render.view_component` notification and tracks which component templates are rendered, just like standard Rails views.
|
|
309
|
+
|
|
274
310
|

|
|
275
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
|
+
|
|
276
361
|
### Hiding settings
|
|
277
362
|
|
|
278
363
|
Coverband provides a view of all of its current settings. Sometimes you might want to hide this view,
|
|
@@ -281,6 +366,16 @@ You can disable the settings view like so:
|
|
|
281
366
|
|
|
282
367
|
`config.hide_settings = true`
|
|
283
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
|
+
|
|
284
379
|
### Fixing Coverage Only Shows Loading Hits
|
|
285
380
|
|
|
286
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`.
|
|
@@ -314,6 +409,8 @@ Add a wiggle (in seconds) to the background thread to avoid all your servers rep
|
|
|
314
409
|
|
|
315
410
|
`config.reporting_wiggle = 30`
|
|
316
411
|
|
|
412
|
+
The default 30-second wiggle is suitable for most deployments. For larger fleets you may want to increase the wiggle proportionally (e.g. 1 second per server). The tradeoff is a small delay in coverage reporting freshness. The `reporting_wiggle` and `background_reporting_sleep_seconds` options work together — consider configuring both when tuning background reporting for your environment.
|
|
413
|
+
|
|
317
414
|
Another way to avoid cache stampede is to omit some reporting on starting servers. Coverband stores the results of eager_loading to Redis at server startup. The eager_loading results are the same for all servers, so there is no need to save all results. By configuring the eager_loading results of some servers to be stored in Redis, we can reduce the load on Redis during deployment.
|
|
318
415
|
|
|
319
416
|
```ruby
|
|
@@ -325,11 +422,21 @@ config.send_deferred_eager_loading_data = rand(100) < 5
|
|
|
325
422
|
config.send_deferred_eager_loading_data = ENV.fetch('ENABLE_EAGER_LOADING_COVERAGE', false)
|
|
326
423
|
```
|
|
327
424
|
|
|
425
|
+
Note: `defer_eager_loading_data = true` is required in order to use `send_deferred_eager_loading_data`. With both set, eager_loading data is deferred at startup and only sent by the subset of servers where `send_deferred_eager_loading_data` evaluates to `true`.
|
|
426
|
+
|
|
427
|
+
### Oneshot Mode
|
|
428
|
+
|
|
429
|
+
Enabling [oneshot mode](https://docs.ruby-lang.org/en/master/Coverage.html#module-coverage-oneshot-lines-coverage) reduces the Ruby CPU overhead. The tradeoff is that you no longer get frequency data — you will only know whether a line was executed at least once, not how many times.
|
|
430
|
+
|
|
431
|
+
```ruby
|
|
432
|
+
config.use_oneshot_lines_coverage = true
|
|
433
|
+
```
|
|
434
|
+
|
|
328
435
|
### Redis Hash Store
|
|
329
436
|
|
|
330
437
|
Coverband on very high volume sites with many server processes reporting can have a race condition which can cause hit counts to be inaccurate. To resolve the race condition and reduce Ruby memory overhead we have introduced a new Redis storage option. This moves the some of the work from the Ruby processes to Redis. It is worth noting because of this, it has larger demands on the Redis server. So adjust your Redis instance accordingly. To help reduce the extra redis load you can also change the background reporting frequency.
|
|
331
438
|
|
|
332
|
-
-
|
|
439
|
+
- To use Redis Hash Store: `config.store = Coverband::Adapters::HashRedisStore.new(Redis.new(url: redis_url))`
|
|
333
440
|
- Adjust from default 30s reporting `config.background_reporting_sleep_seconds = 120`
|
|
334
441
|
|
|
335
442
|
See more discussion [here](https://github.com/danmayer/coverband/issues/384).
|
|
@@ -402,10 +509,20 @@ gem 'coverband', require: ['alternative_coverband_patch']
|
|
|
402
509
|
|
|
403
510
|
This conflict happens when a ruby method is patched twice, once using module prepend, and once using method aliasing. See this ruby issue for details. The fix is to apply all patches the same way. By default, Coverband will apply its patch using prepend, but you can change that to method aliasing by adding require: ['alternative_coverband_patch'] to the gem line as shown above.
|
|
404
511
|
|
|
405
|
-
### Redis Sizing Info
|
|
512
|
+
### Redis Sizing & Configuration Info
|
|
406
513
|
|
|
407
514
|
A few folks have asked about what size of Redis is needed to run Coverband. I have some of our largest services with hundreds of servers on cache.m3.medium with plenty of room to spare. I run most apps on the smallest AWS Redis instances available and bump up only if needed or if I am forced to be on a shared Redis instance, which I try to avoid. On Heroku, I have used it with most of the 3rd party and also been fine on the smallest Redis instances, if you have hundreds of dynos you would likely need to scale up. Also note there is a tradeoff one can make, `Coverband::Adapters::HashRedisStore` will use LUA on Redis and increase the Redis load, while being nicer to your app servers and avoid potential lost data during race conditions. While the `Coverband::Adapters::RedisStore` uses in app memory and merging and has lower load on Redis.
|
|
408
515
|
|
|
516
|
+
For a dedicated Coverband Redis instance, `allkeys-lfu` is a good choice for `maxmemory-policy` as it evicts the least-frequently-used keys first, meaning rarely-hit files are evicted before frequently-reported ones. On a shared Redis instance, be cautious with eviction policies that could interfere with other data. See [issue #595](https://github.com/danmayer/coverband/issues/595) for more discussion.
|
|
517
|
+
|
|
518
|
+
### Ruby Overhead Reduction Checklist
|
|
519
|
+
|
|
520
|
+
If Coverband is adding meaningful latency to your application, work through this checklist:
|
|
521
|
+
|
|
522
|
+
* Enable oneshot mode (`config.use_oneshot_lines_coverage = true`) — reduces CPU overhead by only tracking whether a line ran, not how many times
|
|
523
|
+
* Enable Redis Hash Store — moves merging work from Ruby processes to Redis, reducing per-process memory overhead
|
|
524
|
+
* Only enable Coverband on a subset of server instances — requests routed to those servers will be slightly slower, but coverage data will still be gathered. This is most effective with a Least Outstanding Requests load balancing algorithm. Avoid enabling on all servers but only reporting on a subset of requests — loading the coverage module itself has a performance impact regardless of reporting.
|
|
525
|
+
|
|
409
526
|
# Newer Features
|
|
410
527
|
|
|
411
528
|
### MCP Server for AI Assistants
|
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,19 @@
|
|
|
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
|
+
|
|
7
|
+
### 6.2.1
|
|
8
|
+
|
|
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)
|
|
10
|
+
* Feature: Added support for TLS Redis connections via `rediss://` URL scheme for use with serverless ElastiCache and other TLS-required Redis services (#580)
|
|
11
|
+
* Fix: MCP HTTP handler now properly uses `StreamableHTTPTransport` for full HTTP protocol support including GET, DELETE, and OPTIONS methods (#604)
|
|
12
|
+
* Fix: Suppressed Rails logger stdout pollution in stdio MCP mode to ensure clean JSON-RPC stream for Claude Desktop and other AI clients (#625)
|
|
13
|
+
* Fix: Tracker persistence now gracefully handles non-Redis stores (FileStore, NullStore) without raising errors at process exit (#637)
|
|
14
|
+
* Fix: Resolved Rack 1.6 compatibility issue in web reporter by detecting `Rack::Files` vs `Rack::File` class (#639)
|
|
15
|
+
* Docs: Added TLS Redis setup documentation to README for serverless ElastiCache configurations
|
|
16
|
+
|
|
1
17
|
### 6.2.0
|
|
2
18
|
|
|
3
19
|
* Fix: Correct `Rack::Static` URL handling in the web reporter by using an empty string URL prefix; thanks @alpaca-tc (#635)
|
|
@@ -53,6 +53,8 @@ module Coverband
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def used_keys
|
|
56
|
+
return {} unless redis_store
|
|
57
|
+
|
|
56
58
|
redis_store.hgetall(tracker_key)
|
|
57
59
|
end
|
|
58
60
|
|
|
@@ -74,6 +76,8 @@ module Coverband
|
|
|
74
76
|
end
|
|
75
77
|
|
|
76
78
|
def tracking_since
|
|
79
|
+
return "N/A" unless redis_store
|
|
80
|
+
|
|
77
81
|
if (tracking_time = redis_store.get(tracker_time_key))
|
|
78
82
|
Time.at(tracking_time.to_i).iso8601
|
|
79
83
|
else
|
|
@@ -82,18 +86,24 @@ module Coverband
|
|
|
82
86
|
end
|
|
83
87
|
|
|
84
88
|
def reset_recordings
|
|
89
|
+
return unless redis_store
|
|
90
|
+
|
|
85
91
|
redis_store.del(tracker_key)
|
|
86
92
|
redis_store.del(tracker_time_key)
|
|
87
93
|
end
|
|
88
94
|
|
|
89
95
|
def clear_key!(key)
|
|
90
96
|
return unless key
|
|
97
|
+
return unless redis_store
|
|
98
|
+
|
|
91
99
|
puts "#{tracker_key} key #{key}"
|
|
92
100
|
redis_store.hdel(tracker_key, key)
|
|
93
101
|
@logged_keys.delete(key)
|
|
94
102
|
end
|
|
95
103
|
|
|
96
104
|
def save_report
|
|
105
|
+
return unless redis_store
|
|
106
|
+
|
|
97
107
|
redis_store.set(tracker_time_key, Time.now.to_i) unless @one_time_timestamp || tracker_time_key_exists?
|
|
98
108
|
@one_time_timestamp = true
|
|
99
109
|
reported_time = Time.now.to_i
|
|
@@ -138,10 +148,16 @@ module Coverband
|
|
|
138
148
|
end
|
|
139
149
|
|
|
140
150
|
def redis_store
|
|
141
|
-
|
|
151
|
+
@redis_store ||= begin
|
|
152
|
+
store.raw_store
|
|
153
|
+
rescue NotImplementedError
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
142
156
|
end
|
|
143
157
|
|
|
144
158
|
def tracker_time_key_exists?
|
|
159
|
+
return false unless redis_store
|
|
160
|
+
|
|
145
161
|
if defined?(redis_store.exists?)
|
|
146
162
|
redis_store.exists?(tracker_time_key)
|
|
147
163
|
else
|
|
@@ -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
|
|
@@ -33,6 +33,13 @@ module Coverband
|
|
|
33
33
|
ActiveSupport::Notifications.subscribe(/render_(template|partial|collection).action_view/) do |name, start, finish, id, payload|
|
|
34
34
|
Coverband.configuration.view_tracker.track_key(payload) unless name.include?("!")
|
|
35
35
|
end
|
|
36
|
+
|
|
37
|
+
# ViewComponent >= 4.6.0 emits render.view_component with a view_identifier key
|
|
38
|
+
# containing the template path. Requires config.view_component.instrumentation_enabled = true
|
|
39
|
+
# in the host app.
|
|
40
|
+
ActiveSupport::Notifications.subscribe("render.view_component") do |name, start, finish, id, payload|
|
|
41
|
+
Coverband.configuration.view_tracker.track_key(identifier: payload[:view_identifier]) unless name.include?("!")
|
|
42
|
+
end
|
|
36
43
|
end
|
|
37
44
|
|
|
38
45
|
###
|
|
@@ -61,6 +68,8 @@ module Coverband
|
|
|
61
68
|
end
|
|
62
69
|
|
|
63
70
|
def used_keys
|
|
71
|
+
return {} unless redis_store
|
|
72
|
+
|
|
64
73
|
views = redis_store.hgetall(tracker_key)
|
|
65
74
|
normalized_views = {}
|
|
66
75
|
views.each_pair do |view, time|
|
|
@@ -93,6 +102,7 @@ module Coverband
|
|
|
93
102
|
|
|
94
103
|
def clear_key!(filename)
|
|
95
104
|
return unless filename
|
|
105
|
+
return unless redis_store
|
|
96
106
|
|
|
97
107
|
filename = "#{@project_directory}/#{filename}"
|
|
98
108
|
redis_store.hdel(tracker_key, filename)
|
|
@@ -126,7 +136,7 @@ module Coverband
|
|
|
126
136
|
|
|
127
137
|
def concrete_target
|
|
128
138
|
if defined?(Rails.application)
|
|
129
|
-
Dir.glob("#{@project_directory}/{,packs
|
|
139
|
+
Dir.glob("#{@project_directory}/{,packs/*,engines/*}/app/{views,components}/**/*.html.{erb,haml,slim}")
|
|
130
140
|
else
|
|
131
141
|
[]
|
|
132
142
|
end
|