Skip to content

Commit

Permalink
resolve #457 support tracking translation keys
Browse files Browse the repository at this point in the history
  • Loading branch information
Dan Mayer committed Dec 28, 2022
1 parent d506002 commit a78d37c
Show file tree
Hide file tree
Showing 13 changed files with 401 additions and 11 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ If you submit a change please make sure the tests and benchmarks are passing.
- run the benchmarks before and after your change to see impact
- `rake benchmarks`
- run a single test by line number like rspec: `bundle exec m test/coverband/reporters/html_test.rb:29`
- run a single test file: `bundle exec ruby test/coverband/collectors/translation_tracker_test.rb`

### Known Issues

Expand Down
4 changes: 4 additions & 0 deletions changes.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
### Coverband 5.2.6

- add support for translation keys

### Coverband 5.2.5

- (colemanja91/danmayer) experimental support for route tracking, opt-in
Expand Down
1 change: 1 addition & 0 deletions lib/coverband.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
require "coverband/collectors/view_tracker"
require "coverband/collectors/view_tracker_service"
require "coverband/collectors/route_tracker"
require "coverband/collectors/translation_tracker"
require "coverband/reporters/base"
require "coverband/reporters/console_report"
require "coverband/integrations/background"
Expand Down
183 changes: 183 additions & 0 deletions lib/coverband/collectors/translation_tracker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# frozen_string_literal: true

require "set"
require "singleton"

module Coverband
module Collectors
module I18n
module KeyRegistry
def lookup(locale, key, scope = [], options = {})
separator = options[:separator] || ::I18n.default_separator
flat_key = ::I18n.normalize_keys(locale, key, scope, separator).join(separator)
Coverband.configuration.translations_tracker.track_key(flat_key)

super
end
end
end

###
# This class tracks translation usage via I18n::Backend
###
class TranslationTracker
attr_accessor :target
attr_reader :logger, :store, :ignore_patterns

def initialize(options = {})
raise NotImplementedError, "#{self.class.name} requires Rails 4 or greater" 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
if defined?(Rails.application)
# I18n.eager_load!
# I18n.backend.send(:translations)
app_translation_keys = []
app_translation_files = ::I18n.load_path.select { |f| f.match(/config\/locales/) }
app_translation_files.each do |file|
app_translation_keys += flatten_hash(YAML.load_file(file)).keys
end
app_translation_keys.uniq
else
[]
end
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

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

def self.supported_version?
defined?(Rails) && defined?(Rails::VERSION) && Rails::VERSION::STRING.split(".").first.to_i >= 4
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 flatten_hash(hash)
hash.each_with_object({}) do |(k, v), h|
if v.is_a? Hash
flatten_hash(v).map do |h_k, h_v|
h["#{k}.#{h_k}".to_sym] = h_v
end
else
h[k] = v
end
end
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
11 changes: 7 additions & 4 deletions lib/coverband/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ class Configuration
:test_env, :web_enable_clear, :gem_details, :web_debug, :report_on_exit,
:simulate_oneshot_lines_coverage,
:view_tracker, :defer_eager_loading_data,
:track_routes, :route_tracker
:track_routes, :route_tracker,
:track_translations, :translations_tracker
attr_writer :logger, :s3_region, :s3_bucket, :s3_access_key_id,
:s3_secret_access_key, :password, :api_key, :service_url, :coverband_timeout, :service_dev_mode,
:service_test_mode, :process_type, :track_views, :redis_url,
Expand Down Expand Up @@ -70,6 +71,8 @@ def reset
@view_tracker = nil
@track_routes = false
@route_tracker = nil
@track_translations = false
@translations_tracker = nil
@web_debug = false
@report_on_exit = true
@use_oneshot_lines_coverage = ENV["ONESHOT"] || false
Expand Down Expand Up @@ -119,7 +122,7 @@ def password
def background_reporting_sleep_seconds
@background_reporting_sleep_seconds ||= if service?
# default to 10m for service
Coverband.configuration.coverband_env == "production" ? 600 : 60
(Coverband.configuration.coverband_env == "production") ? 600 : 60
elsif store.is_a?(Coverband::Adapters::HashRedisStore)
# Default to 5 minutes if using the hash redis store
300
Expand Down Expand Up @@ -224,11 +227,11 @@ def service_url
end

def coverband_env
ENV["RACK_ENV"] || ENV["RAILS_ENV"] || (defined?(Rails) && Rails.respond_to?(:env) ? Rails.env : "unknown")
ENV["RACK_ENV"] || ENV["RAILS_ENV"] || ((defined?(Rails) && Rails.respond_to?(:env)) ? Rails.env : "unknown")
end

def coverband_timeout
@coverband_timeout ||= coverband_env == "development" ? 5 : 2
@coverband_timeout ||= (coverband_env == "development") ? 5 : 2
end

def service_dev_mode
Expand Down
1 change: 1 addition & 0 deletions lib/coverband/integrations/background.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def self.start
Coverband.report_coverage
Coverband.configuration.view_tracker&.report_views_tracked
Coverband.configuration.route_tracker&.report_routes_tracked
Coverband.configuration.translations_tracker&.save_report
if Coverband.configuration.verbose
logger.debug("Coverband: background reporting coverage (#{Coverband.configuration.store.type}). Sleeping #{sleep_seconds}s")
end
Expand Down
41 changes: 39 additions & 2 deletions lib/coverband/reporters/web.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,17 @@ def call(env)

return [401, {"www-authenticate" => 'Basic realm=""'}, [""]] unless check_auth

request_path_info = request.path_info == "" ? "/" : request.path_info
request_path_info = (request.path_info == "") ? "/" : request.path_info
if request.post?
case request_path_info
when %r{\/clear_route_tracking_route}
clear_route_tracking_route
when %r{\/clear_route_tracking}
clear_route_tracking
when %r{\/clear_translation_tracking_key}
clear_route_translation_key
when %r{\/clear_translation_tracking}
clear_translation_tracking
when %r{\/clear_view_tracking_file}
clear_view_tracking_file
when %r{\/clear_view_tracking}
Expand All @@ -65,6 +69,8 @@ def call(env)
[200, {"Content-Type" => "text/html"}, [view_tracker]]
when %r{\/route_tracker}
[200, {"Content-Type" => "text/html"}, [route_tracker]]
when %r{\/translations_tracker}
[200, {"Content-Type" => "text/html"}, [translations_tracker]]
when %r{\/enriched_debug_data}
[200, {"Content-Type" => "text/json"}, [enriched_debug_data]]
when %r{\/debug_data}
Expand Down Expand Up @@ -109,6 +115,14 @@ def route_tracker
base_path: base_path).format_route_tracker!
end

def translations_tracker
notice = "<strong>Notice:</strong> #{Rack::Utils.escape_html(request.params["notice"])}<br/>"
notice = request.params["notice"] ? notice : ""
Coverband::Utils::HTMLFormatter.new(nil,
notice: notice,
base_path: base_path).format_translations_tracker!
end

def view_tracker_data
Coverband::Collectors::ViewTracker.new(store: Coverband.configuration.store).as_json
end
Expand Down Expand Up @@ -200,6 +214,29 @@ def clear_route_tracking_route
[302, {"Location" => "#{base_path}/route_tracker?notice=#{notice}"}, []]
end

def clear_translation_tracking
if Coverband.configuration.web_enable_clear
tracker = Coverband::Collectors::TranslationTracker.new(store: Coverband.configuration.store)
tracker.reset_recordings
notice = "translation tracking reset"
else
notice = "web_enable_clear isn't enabled in your configuration"
end
[302, {"Location" => "#{base_path}/translations_tracker?notice=#{notice}"}, []]
end

def clear_translation_tracking_key
if Coverband.configuration.web_enable_clear
tracker = Coverband::Collectors::TranslationTracker.new(store: Coverband.configuration.store)
key = request.params["key"]
tracker.clear_key!(key)
notice = "coverage for route #{key} cleared"
else
notice = "web_enable_clear isn't enabled in your configuration"
end
[302, {"Location" => "#{base_path}/translations_tracker?notice=#{notice}"}, []]
end

private

# This method should get the root mounted endpoint
Expand All @@ -211,7 +248,7 @@ def clear_route_tracking_route
# %r{\/.*\/}.match?(request.path) ? request.path.match("\/.*\/")[0] : "/"
# ^^ the above is NOT valid Ruby 2.3/2.4 even though rubocop / standard think it is
def base_path
request.path =~ %r{\/.*\/} ? request.path.match("\/.*\/")[0] : "/"
(request.path =~ %r{\/.*\/}) ? request.path.match("/.*/")[0] : "/"
end
end
end
Expand Down
14 changes: 11 additions & 3 deletions lib/coverband/utils/html_formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ class HTMLFormatter
attr_reader :notice, :base_path

def initialize(report, options = {})
@notice = options.fetch(:notice) { nil }
@base_path = options.fetch(:base_path) { "./" }
@notice = options.fetch(:notice, nil)
@base_path = options.fetch(:base_path, "./")
@coverage_result = Coverband::Utils::Results.new(report) if report
end

Expand All @@ -41,6 +41,10 @@ def format_route_tracker!
format_route_tracker
end

def format_translations_tracker!
format_translations_tracker
end

def format_source_file!(filename)
source_file = @coverage_result.file_from_path_with_type(filename)

Expand All @@ -65,6 +69,10 @@ def format_route_tracker
template("route_tracker").result(binding)
end

def format_translations_tracker
template("translations_tracker").result(binding)
end

def format(result)
Dir[File.join(File.dirname(__FILE__), "../../../public/*")].each do |path|
FileUtils.cp_r(path, asset_output_path)
Expand Down Expand Up @@ -109,7 +117,7 @@ def assets_path(name)
end

def button(url, title, opts = {})
delete = opts.fetch(:delete) { false }
delete = opts.fetch(:delete, false)
button_css = delete ? "coveraband-button del" : "coveraband-button"
button = "<form action='#{url}' class='coverband-admin-form' method='post'>"
button += "<button class='#{button_css}' type='submit'>#{title}</button>"
Expand Down
7 changes: 7 additions & 0 deletions lib/coverband/utils/railtie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ class Railtie < Rails::Railtie
end
end

if Coverband.configuration.track_translations
Coverband.configuration.translations_tracker = Coverband::Collectors::TranslationTracker.new

# plugin to i18n
I18n::Backend::Simple.send :include, Coverband::Collectors::I18n::KeyRegistry
end

if Coverband.configuration.track_views
COVERBAND_VIEW_TRACKER = if Coverband.coverband_service?
Coverband::Collectors::ViewTrackerService.new
Expand Down
Loading

0 comments on commit a78d37c

Please sign in to comment.