coverband 6.2.2 → 7.0.0.rc.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/diagram.yml +2 -2
  3. data/.github/workflows/main.yml +64 -7
  4. data/Gemfile +16 -0
  5. data/Gemfile.rails7.0 +12 -0
  6. data/Gemfile.rails7.1 +6 -1
  7. data/Gemfile.rails7.2 +5 -0
  8. data/Gemfile.rails8.0 +15 -0
  9. data/README.md +218 -12
  10. data/Rakefile +16 -5
  11. data/changes.md +60 -0
  12. data/coverband.gemspec +15 -13
  13. data/lib/coverband/adapters/active_support_cache_store.rb +54 -0
  14. data/lib/coverband/adapters/base.rb +42 -10
  15. data/lib/coverband/adapters/file_store.rb +2 -2
  16. data/lib/coverband/adapters/hash_redis_store.rb +22 -3
  17. data/lib/coverband/adapters/null_store.rb +0 -4
  18. data/lib/coverband/adapters/redis_cleanup.rb +101 -0
  19. data/lib/coverband/adapters/redis_store.rb +32 -65
  20. data/lib/coverband/adapters/session_coverage.rb +172 -0
  21. data/lib/coverband/adapters/stdout_store.rb +0 -4
  22. data/lib/coverband/adapters/tracker_storage/base.rb +79 -0
  23. data/lib/coverband/adapters/tracker_storage/document_repository.rb +49 -0
  24. data/lib/coverband/adapters/tracker_storage/factory.rb +57 -0
  25. data/lib/coverband/adapters/tracker_storage/redis_hash_repository.rb +127 -0
  26. data/lib/coverband/collectors/abstract_tracker.rb +135 -51
  27. data/lib/coverband/collectors/query_burst_tracker.rb +72 -19
  28. data/lib/coverband/collectors/route_tracker.rb +6 -0
  29. data/lib/coverband/collectors/tracker_registry.rb +63 -0
  30. data/lib/coverband/collectors/translation_tracker.rb +6 -0
  31. data/lib/coverband/collectors/view_tracker.rb +11 -5
  32. data/lib/coverband/configuration.rb +95 -131
  33. data/lib/coverband/mcp/http_handler.rb +1 -1
  34. data/lib/coverband/mcp/server.rb +4 -1
  35. data/lib/coverband/mcp/tools/get_storage_health.rb +28 -0
  36. data/lib/coverband/reporters/base.rb +0 -2
  37. data/lib/coverband/reporters/html_report.rb +2 -3
  38. data/lib/coverband/reporters/json_report.rb +2 -2
  39. data/lib/coverband/reporters/web.rb +72 -15
  40. data/lib/coverband/storage/document.rb +173 -0
  41. data/lib/coverband/storage/generation.rb +165 -0
  42. data/lib/coverband/storage/generation_change.rb +53 -0
  43. data/lib/coverband/storage/generation_coordinator.rb +145 -0
  44. data/lib/coverband/storage/io_guard.rb +29 -0
  45. data/lib/coverband/storage/read_fallback.rb +30 -0
  46. data/lib/coverband/storage/redis_target.rb +74 -0
  47. data/lib/coverband/storage/session.rb +527 -0
  48. data/lib/coverband/storage/target.rb +117 -0
  49. data/lib/coverband/storage/writer.rb +194 -0
  50. data/lib/coverband/storage_health.rb +90 -0
  51. data/lib/coverband/utils/configuration_template.rb +14 -1
  52. data/lib/coverband/utils/railtie.rb +2 -2
  53. data/lib/coverband/utils/result.rb +5 -5
  54. data/lib/coverband/utils/results.rb +32 -16
  55. data/lib/coverband/utils/source_file.rb +17 -27
  56. data/lib/coverband/utils/tasks.rb +71 -6
  57. data/lib/coverband/version.rb +1 -1
  58. data/lib/coverband.rb +98 -20
  59. data/public/application.css +540 -146
  60. data/test/benchmarks/benchmark.rake +13 -0
  61. data/test/coverband/adapters/active_support_cache_store_test.rb +613 -0
  62. data/test/coverband/adapters/capabilities_test.rb +80 -0
  63. data/test/coverband/adapters/redis_store_test.rb +63 -2
  64. data/test/coverband/adapters/solid_cache_store_test.rb +181 -0
  65. data/test/coverband/adapters/tracker_storage_test.rb +170 -0
  66. data/test/coverband/collectors/query_burst_tracker_test.rb +155 -10
  67. data/test/coverband/collectors/route_tracker_test.rb +2 -13
  68. data/test/coverband/collectors/tracker_registry_test.rb +218 -0
  69. data/test/coverband/collectors/translation_tracker_test.rb +76 -16
  70. data/test/coverband/collectors/view_tracker_test.rb +49 -12
  71. data/test/coverband/configuration_test.rb +36 -38
  72. data/test/coverband/mcp/server_test.rb +2 -1
  73. data/test/coverband/mcp/tools/get_storage_health_test.rb +41 -0
  74. data/test/coverband/reporters/html_test.rb +26 -0
  75. data/test/coverband/reporters/json_test.rb +53 -0
  76. data/test/coverband/reporters/web_test.rb +96 -0
  77. data/test/coverband/storage/generation_coordinator_test.rb +191 -0
  78. data/test/coverband/storage/io_guard_test.rb +127 -0
  79. data/test/coverband/storage/protocol_conformance_test.rb +725 -0
  80. data/test/coverband/storage_health_test.rb +99 -0
  81. data/test/coverband/track_key_test.rb +8 -0
  82. data/test/coverband/utils/cleanup_tasks_test.rb +188 -0
  83. data/test/coverband/utils/file_list_test.rb +2 -2
  84. data/test/coverband/utils/result_test.rb +4 -4
  85. data/test/coverband/utils/results_test.rb +100 -10
  86. data/test/coverband/utils/source_file_test.rb +40 -6
  87. data/test/forked/rails_cache_full_stack_test.rb +64 -0
  88. data/test/forked/rails_full_stack_test.rb +7 -0
  89. data/test/integration/full_stack_deferred_eager_test.rb +0 -1
  90. data/test/integration/full_stack_send_deferred_eager_test.rb +0 -1
  91. data/test/integration/full_stack_test.rb +0 -1
  92. data/test/integration/mcp_integration_test.rb +4 -2
  93. data/test/integration/standalone_json_report_test.rb +46 -0
  94. data/test/integration/without_redis_test.rb +76 -0
  95. data/test/rails7_dummy/config/application.rb +1 -0
  96. data/test/rails7_dummy/config/coverband_missing_redis.rb +0 -2
  97. data/test/rails7_dummy/config/coverband_rails_cache.rb +16 -0
  98. data/test/rails8_dummy/config/application.rb +1 -0
  99. data/test/rails8_dummy/config/coverband_rails_cache.rb +3 -0
  100. data/test/rails_test_helper.rb +2 -1
  101. data/test/test_helper.rb +5 -17
  102. data/views/file_list.erb +2 -2
  103. data/views/source_file.erb +4 -3
  104. metadata +48 -40
  105. data/lib/coverband/adapters/memcached_store.rb +0 -85
  106. data/lib/coverband/adapters/web_service_store.rb +0 -152
  107. data/lib/coverband/collectors/view_tracker_service.rb +0 -59
  108. data/test/coverband/adapters/memecached_store_test.rb +0 -26
  109. data/test/coverband/adapters/web_service_store_test.rb +0 -56
@@ -5,13 +5,23 @@ module Coverband
5
5
  # Configuration parsing and options for the coverband gem.
6
6
  ###
7
7
  class Configuration
8
+ def self.add_tracker_flag(name, default: false)
9
+ name = name.to_sym
10
+ attr_accessor name
11
+ dynamic_flag_defaults[name] = default
12
+ end
13
+
14
+ def self.dynamic_flag_defaults
15
+ @dynamic_flag_defaults ||= {}
16
+ end
17
+
8
18
  attr_accessor :root_paths, :root,
9
19
  :verbose,
10
- :reporter, :redis_namespace, :redis_ttl,
20
+ :redis_namespace, :redis_ttl,
11
21
  :background_reporting_enabled,
12
- :test_env, :web_enable_clear, :gem_details, :web_debug, :report_on_exit,
13
- :use_oneshot_lines_coverage, :simulate_oneshot_lines_coverage,
14
- :view_tracker, :defer_eager_loading_data,
22
+ :test_env, :web_enable_clear, :web_debug, :report_on_exit,
23
+ :use_oneshot_lines_coverage,
24
+ :track_views, :view_tracker, :defer_eager_loading_data,
15
25
  :track_routes, :track_redirect_routes, :route_tracker,
16
26
  :track_translations, :translations_tracker,
17
27
  :track_query_bursts, :query_burst_tracker,
@@ -19,20 +29,42 @@ module Coverband
19
29
  :trackers, :csp_policy, :hide_settings,
20
30
  :mcp_enabled
21
31
 
22
- attr_writer :logger, :s3_region, :s3_bucket, :s3_access_key_id,
23
- :s3_secret_access_key, :password, :api_key, :service_url, :coverband_timeout, :service_dev_mode,
24
- :service_test_mode, :process_type, :track_views, :redis_url,
32
+ attr_writer :logger, :password, :redis_url,
25
33
  :background_reporting_sleep_seconds, :reporting_wiggle,
26
34
  :send_deferred_eager_loading_data, :paged_reporting, :mcp_allowed_environments, :mcp_password
27
35
 
28
- attr_reader :track_gems, :ignore
36
+ attr_reader :ignore
37
+
38
+ HOSTED_SERVICE_REMOVED = "coverband.io is no longer maintained and support for it was removed. " \
39
+ "Delete the setting and configure a store, see Upgrading to 7.0 in the README"
40
+
41
+ # Settings removed in 7.0. Assigning one logs how to upgrade rather than
42
+ # raising NoMethodError from the app's config file during boot.
43
+ REMOVED_SETTINGS = {
44
+ reporter: "it has had no effect since Coverband 5.0. Delete the setting",
45
+ api_key: HOSTED_SERVICE_REMOVED,
46
+ service_url: HOSTED_SERVICE_REMOVED,
47
+ coverband_timeout: HOSTED_SERVICE_REMOVED,
48
+ service_dev_mode: HOSTED_SERVICE_REMOVED,
49
+ service_test_mode: HOSTED_SERVICE_REMOVED,
50
+ process_type: HOSTED_SERVICE_REMOVED,
51
+ s3_region: "the S3 report upload was removed earlier and this setting did nothing. Delete the setting",
52
+ s3_bucket: "the S3 report upload was removed earlier and this setting did nothing. Delete the setting",
53
+ s3_access_key_id: "the S3 report upload was removed earlier and this setting did nothing. Delete the setting",
54
+ s3_secret_access_key: "the S3 report upload was removed earlier and this setting did nothing. Delete the setting",
55
+ track_gems: "gem tracking was removed earlier and this setting did nothing. Delete the setting",
56
+ gem_details: "gem tracking was removed earlier and this setting did nothing. Delete the setting",
57
+ simulate_oneshot_lines_coverage: "it had no production effect. Delete the setting"
58
+ }.freeze
59
+
60
+ REMOVED_SETTINGS.each do |name, fix|
61
+ define_method(:"#{name}=") do |_value|
62
+ logger.warn "Coverband: config.#{name} was removed in 7.0 and is ignored, #{fix}"
63
+ end
64
+ end
29
65
 
30
- #####
31
- # TODO: This is is brittle and not a great solution to avoid deploy time
32
- # actions polluting the 'runtime' metrics
33
- #
34
- # * Perhaps detect heroku deployment ENV var opposed to tasks?
35
- #####
66
+ # Tasks that load application code for deployment or maintenance should not
67
+ # contribute to production runtime coverage.
36
68
  IGNORE_TASKS = ["coverband:clear",
37
69
  "coverband:coverage",
38
70
  "coverband:coverage_server",
@@ -67,7 +99,6 @@ module Coverband
67
99
  @ignore = IGNORE_DEFAULTS.map { |ignore_str| Regexp.new(ignore_str) }
68
100
  @search_paths = TRACKED_DEFAULT_PATHS.dup
69
101
  @verbose = false
70
- @reporter = "scov"
71
102
  @logger = nil
72
103
  @store = nil
73
104
  @background_reporting_enabled = true
@@ -90,7 +121,6 @@ module Coverband
90
121
  @web_debug = false
91
122
  @report_on_exit = true
92
123
  @use_oneshot_lines_coverage = ENV["ONESHOT"] || false
93
- @simulate_oneshot_lines_coverage = false # this is being deprecated
94
124
  @current_root = nil
95
125
  @all_root_paths = nil
96
126
  @all_root_patterns = nil
@@ -103,56 +133,39 @@ module Coverband
103
133
  @mcp_password = nil
104
134
  @mcp_allowed_environments = %w[development test]
105
135
 
106
- # coverband service settings
107
- @api_key = nil
108
- @service_url = nil
109
- @coverband_timeout = nil
110
- @service_dev_mode = nil
111
- @service_test_mode = nil
112
- @process_type = nil
113
-
114
136
  @redis_url = nil
115
137
  @redis_namespace = nil
116
138
  @redis_ttl = 2_592_000 # in seconds. Default is 30 days.
117
139
  @reporting_wiggle = nil
118
140
 
119
141
  @trackers = []
120
-
121
- # TODO: these are deprecated
122
- @s3_region = nil
123
- @s3_bucket = nil
124
- @s3_access_key_id = nil
125
- @s3_secret_access_key = nil
126
- @track_gems = false
127
- @gem_details = false
142
+ @registered_trackers = {}
143
+ self.class.dynamic_flag_defaults.each do |name, default|
144
+ instance_variable_set(:"@#{name}", default)
145
+ end
128
146
  end
129
147
 
130
148
  def railtie!
131
- if Coverband.configuration.track_routes
132
- Coverband.configuration.route_tracker = Coverband::Collectors::RouteTracker.new
133
- trackers << Coverband.configuration.route_tracker
134
- end
135
-
136
- if Coverband.configuration.track_translations
137
- Coverband.configuration.translations_tracker = Coverband::Collectors::TranslationTracker.new
138
- trackers << Coverband.configuration.translations_tracker
139
- end
140
-
141
- if Coverband.configuration.track_views
142
- Coverband.configuration.view_tracker = if Coverband.coverband_service?
143
- Coverband::Collectors::ViewTrackerService.new
144
- else
145
- Coverband::Collectors::ViewTracker.new
149
+ unless store.supports_trackers?
150
+ if Coverband::Collectors::TrackerRegistry.registry.any? { |_name, entry| entry.enabled_proc.call(self) }
151
+ logger.info "Coverband: #{store.class.name} does not support tracking, " \
152
+ "view/route/translation/query burst trackers are disabled. " \
153
+ "Coverband::Adapters::ActiveSupportCacheStore supports tracking on any ActiveSupport::Cache store."
146
154
  end
147
- trackers << Coverband.configuration.view_tracker
155
+ return
148
156
  end
149
157
 
150
- if Coverband.configuration.track_query_bursts
151
- Coverband.configuration.query_burst_tracker = Coverband::Collectors::QueryBurstTracker.new
152
- trackers << Coverband.configuration.query_burst_tracker
158
+ Coverband::Collectors::TrackerRegistry.each do |entry|
159
+ next unless entry.enabled_proc.call(self)
160
+
161
+ tracker = build_tracker(entry)
162
+ setter = :"#{entry.name}="
163
+ public_send(setter, tracker) if respond_to?(setter)
164
+ registered_trackers[entry.name] = tracker
165
+ trackers << tracker
153
166
  end
154
167
  trackers.each { |tracker| tracker.railtie! }
155
- rescue Redis::CannotConnectError => e
168
+ rescue *Coverband.redis_connection_errors => e
156
169
  Coverband.configuration.logger.info "Redis is not available (#{e}), Coverband not configured"
157
170
  Coverband.configuration.logger.info "If this is a setup task like assets:precompile feel free to ignore"
158
171
  end
@@ -170,6 +183,13 @@ module Coverband
170
183
  route_tracker
171
184
  end
172
185
 
186
+ # Look up an initialized tracker by its registered name. Bundled trackers
187
+ # also have their own accessor, trackers registered by an app or gem only
188
+ # have this.
189
+ def tracker_for(name)
190
+ registered_trackers[name.to_sym]
191
+ end
192
+
173
193
  def password
174
194
  @password || ENV["COVERBAND_PASSWORD"]
175
195
  end
@@ -195,13 +215,14 @@ module Coverband
195
215
  @mcp_allowed_environments || %w[development test]
196
216
  end
197
217
 
198
- # The adjustments here either protect the redis or service from being overloaded
218
+ # The adjustments here protect the store from being overloaded
199
219
  # the tradeoff being the delay in when reporting data is available
200
220
  # if running your own redis increasing this number reduces load on the redis CPU
201
221
  def background_reporting_sleep_seconds
202
- @background_reporting_sleep_seconds ||= if service?
203
- # default to 10m for service
204
- (Coverband.configuration.coverband_env == "production") ? 600 : 60
222
+ @background_reporting_sleep_seconds ||= if store.is_a?(Coverband::Adapters::ActiveSupportCacheStore)
223
+ # every report rewrites a whole document, and a cache backed store is
224
+ # the one most likely to be shared with the host application
225
+ 600
205
226
  elsif store.is_a?(Coverband::Adapters::HashRedisStore)
206
227
  # Default to 5 minutes if using the hash redis store
207
228
  300
@@ -215,20 +236,18 @@ module Coverband
215
236
  end
216
237
 
217
238
  def store
218
- @store ||= if service?
219
- if ENV["COVERBAND_REDIS_URL"]
220
- raise "invalid configuration: unclear default store coverband expects either api_key or redis_url"
221
- end
222
-
223
- require "coverband/adapters/web_service_store"
224
- Coverband::Adapters::WebServiceStore.new(service_url)
239
+ @store ||= if !Coverband.redis_available?
240
+ logger.warn "Coverband: no store configured and the redis gem is not installed, so coverage is not being recorded"
241
+ logger.warn "Coverband: add `gem \"redis\"` to your Gemfile to use the default Redis store, or configure one: config.store = Coverband::Adapters::ActiveSupportCacheStore.new { Rails.cache }"
242
+ require "coverband/adapters/null_store"
243
+ Coverband::Adapters::NullStore.new
225
244
  else
226
245
  begin
227
246
  # Redis gem automatically enables TLS when the scheme is 'rediss://'
228
247
  # For serverless ElastiCache or other TLS-required Redis instances, use:
229
248
  # REDIS_URL=rediss://your-endpoint:6379
230
249
  Coverband::Adapters::RedisStore.new(Redis.new(url: redis_url), redis_store_options)
231
- rescue Redis::CannotConnectError => error
250
+ rescue *Coverband.redis_connection_errors => error
232
251
  logger.info "Redis is not available (#{error}), defaulting to NullStore"
233
252
  logger.info "If this is intended, please explicitly configure your store: config.store = Coverband::Adapters::FileStore.new('log/coverage')"
234
253
  require "coverband/adapters/null_store"
@@ -239,24 +258,10 @@ module Coverband
239
258
 
240
259
  def store=(store)
241
260
  raise "Pass in an instance of Coverband::Adapters" unless store.is_a?(Coverband::Adapters::Base)
242
- if api_key && store.class.to_s != "Coverband::Adapters::WebServiceStore"
243
- raise "invalid configuration: only coverband service expects an API Key"
244
- end
245
- if ENV["COVERBAND_REDIS_URL"] &&
246
- defined?(::Coverband::Adapters::WebServiceStore) &&
247
- store.instance_of?(::Coverband::Adapters::WebServiceStore)
248
- raise "invalid configuration: coverband service shouldn't have redis url set"
249
- end
250
261
 
251
262
  @store = store
252
263
  end
253
264
 
254
- def track_views
255
- return false if service_disabled_dev_test_env?
256
-
257
- @track_views
258
- end
259
-
260
265
  ###
261
266
  # Search Paths
262
267
  ###
@@ -295,7 +300,7 @@ module Coverband
295
300
  @all_root_patterns ||= all_root_paths.map { |path| /^#{path}/ }.freeze
296
301
  end
297
302
 
298
- SKIPPED_SETTINGS = %w[@s3_secret_access_key @store @api_key @password @mcp_password]
303
+ SKIPPED_SETTINGS = %w[@store @password @mcp_password]
299
304
  def to_h
300
305
  instance_variables
301
306
  .each_with_object({}) do |var, hash|
@@ -307,38 +312,10 @@ module Coverband
307
312
  @redis_url ||= ENV["COVERBAND_REDIS_URL"] || ENV["REDIS_URL"]
308
313
  end
309
314
 
310
- def api_key
311
- @api_key ||= ENV["COVERBAND_API_KEY"]
312
- end
313
-
314
- def service_url
315
- @service_url ||= ENV["COVERBAND_URL"] || "https://coverband.io"
316
- end
317
-
318
315
  def coverband_env
319
316
  ENV["RACK_ENV"] || ENV["RAILS_ENV"] || ((defined?(Rails) && Rails.respond_to?(:env)) ? Rails.env : "unknown")
320
317
  end
321
318
 
322
- def coverband_timeout
323
- @coverband_timeout ||= (coverband_env == "development") ? 5 : 2
324
- end
325
-
326
- def service_dev_mode
327
- @service_dev_mode ||= ENV["COVERBAND_ENABLE_DEV_MODE"] || false
328
- end
329
-
330
- def service_test_mode
331
- @service_test_mode ||= ENV["COVERBAND_ENABLE_TEST_MODE"] || false
332
- end
333
-
334
- def process_type
335
- @process_type ||= ENV["PROCESS_TYPE"] || "unknown"
336
- end
337
-
338
- def service?
339
- Coverband.coverband_service? || !api_key.nil?
340
- end
341
-
342
319
  def defer_eager_loading_data?
343
320
  @defer_eager_loading_data
344
321
  end
@@ -351,35 +328,22 @@ module Coverband
351
328
  !!@paged_reporting
352
329
  end
353
330
 
354
- def service_disabled_dev_test_env?
355
- return false unless service?
356
-
357
- (coverband_env == "test" && !Coverband.configuration.service_test_mode) ||
358
- (coverband_env == "development" && !Coverband.configuration.service_dev_mode)
359
- end
360
-
361
- def s3_bucket
362
- puts "deprecated, s3 is no longer support"
363
- end
364
-
365
- def s3_region
366
- puts "deprecated, s3 is no longer support"
367
- end
368
-
369
- def s3_access_key_id
370
- puts "deprecated, s3 is no longer support"
371
- end
331
+ private
372
332
 
373
- def s3_secret_access_key
374
- puts "deprecated, s3 is no longer support"
333
+ def registered_trackers
334
+ @registered_trackers ||= {}
375
335
  end
376
336
 
377
- def track_gems=(_value)
378
- puts "gem tracking is deprecated, setting this will be ignored & eventually removed"
337
+ def build_tracker(entry)
338
+ tracker = if entry.tracker_class.respond_to?(:call)
339
+ entry.tracker_class.call
340
+ else
341
+ entry.tracker_class.new
342
+ end
343
+ Coverband::Collectors::TrackerRegistry.validate_report_route!(entry.name, tracker.class)
344
+ tracker
379
345
  end
380
346
 
381
- private
382
-
383
347
  def redis_store_options
384
348
  {ttl: Coverband.configuration.redis_ttl,
385
349
  redis_namespace: Coverband.configuration.redis_namespace}
@@ -93,7 +93,7 @@ module Coverband
93
93
  return false unless token
94
94
 
95
95
  # Compare with configured MCP password
96
- token == mcp_password
96
+ Rack::Utils.secure_compare(mcp_password.to_s, token)
97
97
  end
98
98
 
99
99
  def transport
@@ -7,6 +7,7 @@ require_relative "tools/get_dead_methods"
7
7
  require_relative "tools/get_view_tracker_data"
8
8
  require_relative "tools/get_route_tracker_data"
9
9
  require_relative "tools/get_translation_tracker_data"
10
+ require_relative "tools/get_storage_health"
10
11
 
11
12
  module Coverband
12
13
  module MCP
@@ -66,6 +67,7 @@ module Coverband
66
67
  - get_view_tracker_data
67
68
  - get_route_tracker_data
68
69
  - get_translation_tracker_data
70
+ - get_storage_health
69
71
 
70
72
  For Claude Desktop, configure with:
71
73
  {
@@ -108,7 +110,8 @@ module Coverband
108
110
  Tools::GetDeadMethods,
109
111
  Tools::GetViewTrackerData,
110
112
  Tools::GetRouteTrackerData,
111
- Tools::GetTranslationTrackerData
113
+ Tools::GetTranslationTrackerData,
114
+ Tools::GetStorageHealth
112
115
  ]
113
116
  end
114
117
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coverband
4
+ module MCP
5
+ module Tools
6
+ class GetStorageHealth < ::MCP::Tool
7
+ description "Get Coverband's observed coverage and tracker storage protocol state, " \
8
+ "including stalled writes and recorded data loss. This is not an active backend probe."
9
+
10
+ input_schema(
11
+ properties: {}
12
+ )
13
+
14
+ def self.call(server_context:, **)
15
+ ::MCP::Tool::Response.new([{
16
+ type: "text",
17
+ text: JSON.pretty_generate(Coverband.storage_health)
18
+ }])
19
+ rescue => e
20
+ ::MCP::Tool::Response.new([{
21
+ type: "text",
22
+ text: "Error getting storage health: #{e.message}"
23
+ }])
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -84,8 +84,6 @@ module Coverband
84
84
  # basically because paths on machines or deployed hosts could be different, so
85
85
  # two different keys could point to the same filename or `line_key`
86
86
  # this happens when deployment has a dynamic path or the path change during deployment (hot code reload)
87
- # TODO: think we are filtering based on ignore while sending to the store
88
- # and as we also pull it out here
89
87
  ###
90
88
  def get_current_scov_data_imp(store, roots, options = {})
91
89
  scov_style_report = {}
@@ -9,14 +9,13 @@ module Coverband
9
9
  def initialize(store, options = {})
10
10
  self.page = options.fetch(:page) { nil }
11
11
  self.open_report = options.fetch(:open_report) { true }
12
- # TODO: refactor notice out to top level of web only
13
12
  self.notice = options.fetch(:notice) { nil }
14
13
  self.base_path = options.fetch(:base_path) { "./" }
15
14
  self.filename = options.fetch(:filename) { nil }
16
15
 
17
16
  coverband_reports = Coverband::Reporters::Base.report(store, options)
18
- # NOTE: at the moment the optimization around paging and filenames only works for hash redis store
19
- self.filtered_report_files = if (page || filename) && store.is_a?(Coverband::Adapters::HashRedisStore)
17
+ # paging and filename optimization only exists on stores that declare it
18
+ self.filtered_report_files = if (page || filename) && store.supports_paged_reports?
20
19
  coverband_reports
21
20
  else
22
21
  self.class.fix_reports(coverband_reports)
@@ -85,7 +85,8 @@ module Coverband
85
85
 
86
86
  data = {
87
87
  **coverage_totals(source_files),
88
- files: coverage_files(result, source_files)
88
+ files: coverage_files(result, source_files),
89
+ storage_health: Coverband.storage_health(coverage: store)
89
90
  }
90
91
 
91
92
  if as_report
@@ -114,7 +115,6 @@ module Coverband
114
115
  data["iTotalDisplayRecords"] = filesreported
115
116
  data["aaData"] = row_data
116
117
  data.delete(:files)
117
- data = data.as_json
118
118
  end
119
119
  data.to_json
120
120
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
4
3
  require "coverband"
5
4
 
6
5
  begin
@@ -42,11 +41,11 @@ module Coverband
42
41
  def check_auth
43
42
  return true unless Coverband.configuration.password
44
43
 
45
- # support rack 1.6.x and rack 2.0 (get_header)
46
- auth_header = request.respond_to?(:get_header) ? request.get_header("HTTP_AUTHORIZATION") : request.env["HTTP_AUTHORIZATION"]
47
- return unless auth_header
44
+ auth = Rack::Auth::Basic::Request.new(request.env)
45
+ return false unless auth.provided? && auth.basic? && auth.credentials
48
46
 
49
- Coverband.configuration.password == Base64.decode64(auth_header.split[1]).split(":")[1]
47
+ # credentials splits on the first colon only, so passwords may contain one
48
+ Rack::Utils.secure_compare(Coverband.configuration.password.to_s, auth.credentials.last.to_s)
50
49
  end
51
50
 
52
51
  def self.call(env)
@@ -114,6 +113,7 @@ module Coverband
114
113
  def index
115
114
  notice = "<strong>Notice:</strong> #{Rack::Utils.escape_html(request.params["notice"])}<br/>"
116
115
  notice = request.params["notice"] ? notice : ""
116
+ notice += storage_notices(Coverband.configuration.store, subject: "coverage")
117
117
  page = (request.params["page"] || 1).to_i
118
118
  options = {
119
119
  static: false,
@@ -153,6 +153,7 @@ 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
+ notice += storage_notices(tracker, subject: "tracker")
156
157
  used_keys_sort = tracker_used_keys_sort_mode(tracker)
157
158
  options = {
158
159
  tracker: tracker,
@@ -189,11 +190,14 @@ module Coverband
189
190
  end
190
191
 
191
192
  def clear
192
- if Coverband.configuration.web_enable_clear
193
- Coverband.configuration.store.clear!
194
- notice = "coverband coverage cleared"
193
+ notice = if Coverband.configuration.web_enable_clear
194
+ if Coverband.configuration.store.clear! == false
195
+ "coverage clear failed, the store did not accept the write"
196
+ else
197
+ "coverband coverage cleared"
198
+ end
195
199
  else
196
- notice = "web_enable_clear isn't enabled in your configuration"
200
+ "web_enable_clear isn't enabled in your configuration"
197
201
  end
198
202
  [302, {"Location" => "#{base_path}?notice=#{notice}"}, []]
199
203
  end
@@ -202,19 +206,72 @@ module Coverband
202
206
  if Coverband.configuration.web_enable_clear
203
207
  filename = request.params["filename"]
204
208
  Coverband.configuration.store.clear_file!(filename)
205
- notice = "coverage for file #{filename} cleared"
209
+ notice = "coverage clear submitted for file #{filename}, applied within a reporting cycle"
206
210
  else
207
211
  notice = "web_enable_clear isn't enabled in your configuration"
208
212
  end
209
213
  [302, {"Location" => "#{base_path}?notice=#{notice}"}, []]
210
214
  end
211
215
 
216
+ ###
217
+ # A cache can drop what we wrote, and a conflicted delta can outlive its
218
+ # retention caps. Both are repaired as far as they can be, but the numbers
219
+ # on screen are partial after either, so say so rather than implying the
220
+ # report is complete.
221
+ ###
222
+ ###
223
+ # Nothing is lost, but nothing is arriving either, and an empty tab reads
224
+ # exactly like an app that used nothing. Process-local, unlike a recorded
225
+ # loss -- when the failure is that nothing can be stored, what this process
226
+ # is holding is the only evidence there is.
227
+ ###
228
+ def storage_notices(source, subject:)
229
+ health = Coverband::StorageHealth.for_source(source)
230
+ data_loss_notice(health, subject: subject) + unwritten_notice(health, subject: subject)
231
+ end
232
+
233
+ def unwritten_notice(health, subject:)
234
+ held = health[:unwritten]
235
+ return "" unless held
236
+
237
+ deltas = held[:deltas]
238
+ "<strong>Notice:</strong> #{deltas} #{subject} #{(deltas == 1) ? "report" : "reports"} " \
239
+ "could not be stored, the oldest since #{held[:since]}. What is shown is missing them, " \
240
+ "and the log says why each write was refused.<br/>"
241
+ end
242
+
243
+ def data_loss_notice(health, subject:)
244
+ loss = health[:data_loss]
245
+ return "" unless loss
246
+
247
+ detail = Rack::Utils.escape_html(loss[:detail].to_s)
248
+ kind = Rack::Utils.escape_html(loss[:kind])
249
+ lead, consequence = data_loss_wording(kind, subject)
250
+ "<strong>Notice:</strong> #{lead} at #{loss[:at]} " \
251
+ "(#{kind}): #{detail}. #{consequence}<br/>"
252
+ end
253
+
254
+ # a forfeited retry is not lost data -- that work is in the document unless
255
+ # another writer clobbered the write it went out in -- so it must not read
256
+ # like a real eviction
257
+ def data_loss_wording(kind, subject)
258
+ if kind == "unconfirmed_dropped"
259
+ ["some #{subject} data may be undercounted",
260
+ "Affected only if another process overwrote that write."]
261
+ else
262
+ ["#{subject} data was lost", "Results before that point are unavailable."]
263
+ end
264
+ end
265
+
212
266
  def clear_abstract_tracking(tracker)
213
- if Coverband.configuration.web_enable_clear
214
- tracker.reset_recordings
215
- notice = "#{tracker.title} tracking reset"
267
+ notice = if !Coverband.configuration.web_enable_clear
268
+ "web_enable_clear isn't enabled in your configuration"
269
+ elsif tracker.reset_recordings == false
270
+ # a reset whose pointer write didn't land hasn't happened, and saying
271
+ # otherwise leaves the operator believing data is gone when it is not
272
+ "#{tracker.title} tracking reset failed, the store did not accept the write"
216
273
  else
217
- notice = "web_enable_clear isn't enabled in your configuration"
274
+ "#{tracker.title} tracking reset"
218
275
  end
219
276
  [302, {"Location" => "#{base_path}/#{tracker.route}?notice=#{notice}"}, []]
220
277
  end
@@ -223,7 +280,7 @@ module Coverband
223
280
  if Coverband.configuration.web_enable_clear
224
281
  key = request.params["key"]
225
282
  tracker.clear_key!(key)
226
- notice = "coverage for #{tracker.title} #{key} cleared"
283
+ notice = "coverage for #{tracker.title} #{key} clear submitted, applied within a reporting cycle"
227
284
  else
228
285
  notice = "web_enable_clear isn't enabled in your configuration"
229
286
  end