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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/main.yml +10 -2
  3. data/.gitignore +2 -1
  4. data/README.md +119 -2
  5. data/Rakefile +9 -1
  6. data/changes.md +16 -0
  7. data/lib/coverband/collectors/abstract_tracker.rb +17 -1
  8. data/lib/coverband/collectors/delta.rb +21 -7
  9. data/lib/coverband/collectors/query_burst_tracker.rb +268 -0
  10. data/lib/coverband/collectors/view_tracker.rb +11 -1
  11. data/lib/coverband/configuration.rb +14 -0
  12. data/lib/coverband/mcp/http_handler.rb +27 -26
  13. data/lib/coverband/reporters/web.rb +28 -2
  14. data/lib/coverband/utils/configuration_template.rb +4 -0
  15. data/lib/coverband/utils/file_list.rb +1 -0
  16. data/lib/coverband/utils/html_formatter.rb +7 -2
  17. data/lib/coverband/utils/tasks.rb +18 -0
  18. data/lib/coverband/version.rb +1 -1
  19. data/lib/coverband.rb +3 -2
  20. data/public/application.css +27 -0
  21. data/public/application.js +246 -0
  22. data/public/dead_code_session.js +173 -0
  23. data/test/benchmarks/benchmark.rake +18 -10
  24. data/test/benchmarks/benchmark_file_list_covered_lines.rb +54 -0
  25. data/test/coverband/benchmarks/benchmark_memory_leak_check_test.rb +43 -0
  26. data/test/coverband/collectors/delta_test.rb +17 -0
  27. data/test/coverband/collectors/query_burst_tracker_test.rb +77 -0
  28. data/test/coverband/collectors/view_tracker_test.rb +27 -0
  29. data/test/coverband/configuration_test.rb +12 -0
  30. data/test/coverband/mcp/http_handler_test.rb +58 -46
  31. data/test/coverband/reporters/web_test.rb +67 -0
  32. data/test/coverband/track_key_test.rb +9 -0
  33. data/test/forked/rails_route_tracker_stack_test.rb +1 -1
  34. data/test/forked/rails_view_tracker_stack_test.rb +1 -1
  35. data/test/integration/mcp_integration_test.rb +3 -3
  36. data/test/javascript/dead_code_session.test.mjs +123 -0
  37. data/views/abstract_tracker.erb +49 -2
  38. data/views/layout.erb +1 -0
  39. data/views/nav.erb +5 -0
  40. metadata +7 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 12802541de3827dd777fba9962270fd2f0a6c7b3debb2ef96c4bcca32f02b48d
4
- data.tar.gz: 36fa21009ed2ada842ff90ca4a18195c8bfcddc81ad0951c11f613ede11f6606
3
+ metadata.gz: 20188ba8fca24027b2a7bd367ac88586b7dace04a6eadfd9714bdf509849683e
4
+ data.tar.gz: c2db00de6ffaa32256d083c7d51290a5936f5e2ee46c774eed901f91fa0ba545
5
5
  SHA512:
6
- metadata.gz: 03c7e496735cfe705b926b30140b3d704993b0599e470d290fb9ae672a7b204bf5cfa98adfc73d54eb70df1c71e8cbb9d91a5f85862b86d5c2a1ba1d0d3aa704
7
- data.tar.gz: 6610a4ec327a69de3bd6878cb256a8f9d605fcf2a4104e6699b6b68acbec3ac9aaaeb957569eaf9ac1e949c58c5a6adbcd75055be464da56a11dde5c873dc4b6
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
@@ -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
  ![image](https://raw.github.com/danmayer/coverband/main/docs/coverband_view_tracker.png)
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
- - Use a dedicated Coverband redis instance: `config.store = Coverband::Adapters::HashRedisStore.new(Redis.new(url: redis_url))`
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
- store.raw_store
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 = 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
@@ -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,engines/*}/app/{views,components}/**/*.html.{erb,haml,slim}")
139
+ Dir.glob("#{@project_directory}/{,packs/*,engines/*}/app/{views,components}/**/*.html.{erb,haml,slim}")
130
140
  else
131
141
  []
132
142
  end