Skip to content

Commit

Permalink
Merge pull request danmayer#463 from danmayer/rule_of_threes
Browse files Browse the repository at this point in the history
WIP: refactor to leverage abstract tracker
  • Loading branch information
danmayer committed Feb 11, 2023
2 parents 3ce2b52 + 557eeea commit 5aaabe6
Show file tree
Hide file tree
Showing 22 changed files with 411 additions and 718 deletions.
1 change: 1 addition & 0 deletions lib/coverband.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
require "coverband/adapters/null_store"
require "coverband/utils/file_hasher"
require "coverband/collectors/coverage"
require "coverband/collectors/abstract_tracker"
require "coverband/collectors/view_tracker"
require "coverband/collectors/view_tracker_service"
require "coverband/collectors/route_tracker"
Expand Down
3 changes: 2 additions & 1 deletion lib/coverband/at_exit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ def self.register
Coverband.report_coverage
# to ensure we track mailer views we now need to report views tracking
# at exit as well for rake tasks and background tasks that can trigger email
Coverband.configuration.view_tracker&.report_views_tracked
Coverband.configuration.view_tracker&.save_report
Coverband.configuration.translations_tracker&.save_report
end
end
end
Expand Down
164 changes: 164 additions & 0 deletions lib/coverband/collectors/abstract_tracker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# frozen_string_literal: true

require "set"
require "singleton"

module Coverband
module Collectors
###
# This abstract class makes it easy to track any used/unused with timestamp set of usage
###
class AbstractTracker
REPORT_ROUTE = "/"
TITLE = "abstract"

attr_accessor :target
attr_reader :logger, :store, :ignore_patterns

def initialize(options = {})
raise NotImplementedError, "#{self.class.name} requires a newer version of Rails" unless self.class.supported_version?
raise "Coverband: #{self.class.name} initialized before configuration!" if !Coverband.configured? && ENV["COVERBAND_TEST"] == "test"

@ignore_patterns = Coverband.configuration.ignore
@store = options.fetch(:store) { Coverband.configuration.store }
@logger = options.fetch(:logger) { Coverband.configuration.logger }
@target = options.fetch(:target) do
concrete_target
end

@one_time_timestamp = false

@logged_keys = Set.new
@keys_to_record = Set.new
end

def logged_keys
@logged_keys.to_a
end

def keys_to_record
@keys_to_record.to_a
end

###
# This method is called on every translation usage
###
def track_key(key)
if key
if newly_seen_key?(key)
@logged_keys << key
@keys_to_record << key if track_key?(key)
end
end
end

def used_keys
redis_store.hgetall(tracker_key)
end

def all_keys
target.uniq
end

def unused_keys(used_keys = nil)
recently_used_keys = (used_keys || self.used_keys).keys
all_keys.reject { |k| recently_used_keys.include?(k.to_s) }
end

def as_json
used_keys = self.used_keys
{
unused_keys: unused_keys(used_keys),
used_keys: used_keys
}.to_json
end

def tracking_since
if (tracking_time = redis_store.get(tracker_time_key))
Time.at(tracking_time.to_i).iso8601
else
"N/A"
end
end

def reset_recordings
redis_store.del(tracker_key)
redis_store.del(tracker_time_key)
end

def clear_key!(key)
return unless key
puts "#{tracker_key} key #{key}"
redis_store.hdel(tracker_key, key)
@logged_keys.delete(key)
end

def save_report
redis_store.set(tracker_time_key, Time.now.to_i) unless @one_time_timestamp || tracker_time_key_exists?
@one_time_timestamp = true
reported_time = Time.now.to_i
@keys_to_record.to_a.each do |key|
redis_store.hset(tracker_key, key.to_s, reported_time)
end
@keys_to_record.clear
rescue => e
# we don't want to raise errors if Coverband can't reach redis.
# This is a nice to have not a bring the system down
logger&.error "Coverband: #{self.class.name} failed to store, error #{e.class.name} info #{e.message}"
end

# This is the basic rails version supported, if there is something more unique over ride in subclass
def self.supported_version?
defined?(Rails) && defined?(Rails::VERSION) && Rails::VERSION::STRING.split(".").first.to_i >= 5
end

def route
self.class::REPORT_ROUTE
end

def title
self.class::TITLE
end

protected

def newly_seen_key?(key)
!@logged_keys.include?(key)
end

def track_key?(key, options = {})
@ignore_patterns.none? { |pattern| key.to_s.include?(pattern) }
end

private

def concrete_target
raise "subclass must implement"
end

def redis_store
store.raw_store
end

def tracker_time_key_exists?
if defined?(redis_store.exists?)
redis_store.exists?(tracker_time_key)
else
redis_store.exists(tracker_time_key)
end
end

def tracker_key
"#{class_key}_tracker"
end

def tracker_time_key
"#{class_key}_tracker_time"
end

def class_key
@class_key ||= self.class.name.split("::").last
end
end
end
end
149 changes: 35 additions & 114 deletions lib/coverband/collectors/route_tracker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,51 +8,23 @@ module Collectors
###
# This class tracks route usage via ActiveSupport::Notifications
###
class RouteTracker
attr_accessor :target
attr_reader :logger, :store, :ignore_patterns
class RouteTracker < AbstractTracker
REPORT_ROUTE = "routes_tracker"
TITLE = "Routes"

def initialize(options = {})
raise NotImplementedError, "Route Tracker requires Rails 4 or greater" unless self.class.supported_version?
raise "Coverband: route tracker initialized before configuration!" if !Coverband.configured? && ENV["COVERBAND_TEST"] == "test"

@ignore_patterns = Coverband.configuration.ignore
@store = options.fetch(:store) { Coverband.configuration.store }
@logger = options.fetch(:logger) { Coverband.configuration.logger }
@target = options.fetch(:target) do
if defined?(Rails.application)
Rails.application.routes.routes.map do |route|
{
controller: route.defaults[:controller],
action: route.defaults[:action],
url_path: route.path.spec.to_s.gsub("(.:format)", ""),
verb: route.verb
}
end
else
[]
end
if Rails&.respond_to?(:version) && Gem::Version.new(Rails.version) >= Gem::Version.new("6.0.0") && Gem::Version.new(Rails.version) < Gem::Version.new("7.1.0")
require_relative "../utils/rails6_ext"
end

@one_time_timestamp = false

@logged_routes = Set.new
@routes_to_record = Set.new
end

def logged_routes
@logged_routes.to_a
end

def routes_to_record
@routes_to_record.to_a
super
end

###
# This method is called on every routing call, so we try to reduce method calls
# and ensure high performance
###
def track_routes(_name, _start, _finish, _id, payload)
def track_key(payload)
route = if payload[:request]
{
controller: nil,
Expand All @@ -69,104 +41,53 @@ def track_routes(_name, _start, _finish, _id, payload)
}
end
if route
if newly_seen_route?(route)
@logged_routes << route
@routes_to_record << route if track_route?(route)
if newly_seen_key?(route)
@logged_keys << route
@keys_to_record << route if track_key?(route)
end
end
end

def used_routes
redis_store.hgetall(tracker_key)
end

def all_routes
target.uniq
def self.supported_version?
defined?(Rails) && defined?(Rails::VERSION) && Rails::VERSION::STRING.split(".").first.to_i >= 6
end

def unused_routes(used_routes = nil)
recently_used_routes = (used_routes || self.used_routes).keys
def unused_keys(used_keys = nil)
recently_used_routes = (used_keys || self.used_keys).keys
# NOTE: we match with or without path to handle paths with named params like `/user/:user_id` to used routes filling with all the variable named paths
all_routes.reject { |r| recently_used_routes.include?(r.to_s) || recently_used_routes.include?(r.merge(url_path: nil).to_s) }
end

def as_json
used_routes = self.used_routes
{
unused_routes: unused_routes(used_routes),
used_routes: used_routes
}.to_json
all_keys.reject { |r| recently_used_routes.include?(r.to_s) || recently_used_routes.include?(r.merge(url_path: nil).to_s) }
end

def tracking_since
if (tracking_time = redis_store.get(tracker_time_key))
Time.at(tracking_time.to_i).iso8601
else
"N/A"
def railtie!
ActiveSupport::Notifications.subscribe("start_processing.action_controller") do |name, start, finish, id, payload|
Coverband.configuration.route_tracker.track_key(payload)
end
end

def reset_recordings
redis_store.del(tracker_key)
redis_store.del(tracker_time_key)
end

def clear_route!(route)
return unless route

redis_store.hdel(tracker_key, route)
@logged_routes.delete(route)
end

def report_routes_tracked
redis_store.set(tracker_time_key, Time.now.to_i) unless @one_time_timestamp || tracker_time_key_exists?
@one_time_timestamp = true
reported_time = Time.now.to_i
@routes_to_record.to_a.each do |route|
redis_store.hset(tracker_key, route.to_s, reported_time)
# NOTE: This event was instrumented in Aug 10th 2022, but didn't make the 7.0.4 release and should be in the next release
# https://github.com/rails/rails/pull/43755
# Automatic tracking of redirects isn't avaible before Rails 7.1.0 (currently tested against the 7.1.0.alpha)
# We could consider back porting or patching a solution that works on previous Rails versions
ActiveSupport::Notifications.subscribe("redirect.action_dispatch") do |name, start, finish, id, payload|
Coverband.configuration.route_tracker.track_key(payload)
end
@routes_to_record.clear
rescue => e
# we don't want to raise errors if Coverband can't reach redis.
# This is a nice to have not a bring the system down
logger&.error "Coverband: route_tracker failed to store, error #{e.class.name} info #{e.message}"
end

def self.supported_version?
defined?(Rails) && defined?(Rails::VERSION) && Rails::VERSION::STRING.split(".").first.to_i >= 4
end

protected

def newly_seen_route?(route)
!@logged_routes.include?(route)
end

def track_route?(route, options = {})
@ignore_patterns.none? { |pattern| route.to_s.include?(pattern) }
end

private

def redis_store
store.raw_store
end

def tracker_time_key_exists?
if defined?(redis_store.exists?)
redis_store.exists?(tracker_time_key)
def concrete_target
if defined?(Rails.application)
Rails.application.routes.routes.map do |route|
{
controller: route.defaults[:controller],
action: route.defaults[:action],
url_path: route.path.spec.to_s.gsub("(.:format)", ""),
verb: route.verb
}
end
else
redis_store.exists(tracker_time_key)
[]
end
end

def tracker_key
"route_tracker_2"
end

def tracker_time_key
"route_tracker_time"
end
end
end
end
Loading

0 comments on commit 5aaabe6

Please sign in to comment.