diff --git a/lib/honeybadger/agent.rb b/lib/honeybadger/agent.rb index 8959fa3d..7d7831b3 100644 --- a/lib/honeybadger/agent.rb +++ b/lib/honeybadger/agent.rb @@ -8,7 +8,10 @@ require 'honeybadger/logging' require 'honeybadger/worker' require 'honeybadger/events_worker' +require 'honeybadger/metrics_worker' require 'honeybadger/breadcrumbs' +require 'honeybadger/registry' +require 'honeybadger/registry_execution' module Honeybadger # The Honeybadger agent contains all the methods for interacting with the @@ -358,6 +361,7 @@ def flush ensure worker.flush events_worker&.flush + metrics_worker&.flush end # Stops the Honeybadger service. @@ -367,6 +371,7 @@ def flush def stop(force = false) worker.shutdown(force) events_worker&.shutdown(force) + metrics_worker&.shutdown(force) true end @@ -387,20 +392,46 @@ def stop(force = false) def event(event_type, payload = {}) init_events_worker - ts = DateTime.now.new_offset(0).rfc3339 + ts = Time.now.utc.strftime("%FT%T.%LZ") merged = {ts: ts} if event_type.is_a?(String) - merged.merge!(event_type: event_type) + merged[:event_type] = event_type else merged.merge!(Hash(event_type)) end + if (request_id = context_manager.get_request_id) + merged[:request_id] = request_id + end + + if config[:'events.attach_hostname'] + merged[:hostname] = config[:hostname].to_s + end + merged.merge!(Hash(payload)) + return if config.ignored_events.any? { |check| merged[:event_type]&.match?(check) } + events_worker.push(merged) end + # @api private + def collect(collector) + return unless config.insights_enabled? + + init_metrics_worker + metrics_worker.push(collector) + end + + # @api private + def registry + return @registry if defined?(@registry) + @registry = Honeybadger::Registry.new.tap do |r| + collect(Honeybadger::RegistryExecution.new(r, config, {})) + end + end + # @api private attr_reader :config @@ -467,13 +498,15 @@ def event(event_type, payload = {}) # @api private def with_rack_env(rack_env, &block) context_manager.set_rack_env(rack_env) + context_manager.set_request_id(rack_env["action_dispatch.request_id"] || SecureRandom.uuid) yield ensure context_manager.set_rack_env(nil) + context_manager.set_request_id(nil) end # @api private - attr_reader :worker, :events_worker + attr_reader :worker, :events_worker, :metrics_worker # @api private # @!method init!(...) @@ -485,6 +518,35 @@ def with_rack_env(rack_env, &block) # @see Config#backend def_delegators :config, :backend + # @api private + # @!method time + # @see Honeybadger::Instrumentation#time + def_delegator :instrumentation, :time + + # @api private + # @!method histogram + # @see Honeybadger::Instrumentation#histogram + def_delegator :instrumentation, :histogram + + # @api private + # @!method gauge + # @see Honeybadger::Instrumentation#gauge + def_delegator :instrumentation, :gauge + + # @api private + # @!method increment_counter + # @see Honeybadger::Instrumentation#increment_counter + def_delegator :instrumentation, :increment_counter + + # @api private + # @!method decrement_counter + # @see Honeybadger::Instrumentation#decrement_counter + def_delegator :instrumentation, :decrement_counter + + def instrumentation + @instrumentation ||= Honeybadger::Instrumentation.new(self) + end + private def validate_notify_opts!(opts) @@ -520,6 +582,11 @@ def init_events_worker @events_worker = EventsWorker.new(config) end + def init_metrics_worker + return if @metrics_worker + @metrics_worker = MetricsWorker.new(config) + end + def with_error_handling yield rescue => ex diff --git a/lib/honeybadger/config.rb b/lib/honeybadger/config.rb index 9106db53..f550e72d 100644 --- a/lib/honeybadger/config.rb +++ b/lib/honeybadger/config.rb @@ -188,6 +188,12 @@ def ignored_classes DEFAULTS[:'exceptions.ignore'] | Array(ignore) end + def ignored_events + self[:'events.ignore'].map do |check| + check.is_a?(String) ? /^#{check}$/ : check + end + end + def ca_bundle_path if self[:'connection.system_ssl_cert_chain'] && File.exist?(OpenSSL::X509::DEFAULT_CERT_FILE) OpenSSL::X509::DEFAULT_CERT_FILE @@ -224,6 +230,10 @@ def max_queue_size self[:max_queue_size] end + def events_max_queue_size + self[:'events.max_queue_size'] + end + def events_batch_size self[:'events.batch_size'] end @@ -262,6 +272,28 @@ def load_plugin?(name) includes_token?(self[:plugins], name) end + def insights_enabled? + return false if defined?(::Rails.application) && ::Rails.const_defined?("Console") + !!self[:'insights.enabled'] + end + + def cluster_collection?(name) + return false unless insights_enabled? + return true if self[:"#{name}.insights.cluster_collection"].nil? + !!self[:"#{name}.insights.cluster_collection"] + end + + def collection_interval(name) + return nil unless insights_enabled? + self[:"#{name}.insights.collection_interval"] + end + + def load_plugin_insights?(name) + return false unless insights_enabled? + return true if self[:"#{name}.insights.enabled"].nil? + !!self[:"#{name}.insights.enabled"] + end + def root_regexp return @root_regexp if @root_regexp return nil if @no_root diff --git a/lib/honeybadger/config/defaults.rb b/lib/honeybadger/config/defaults.rb index a07103ca..dd005bed 100644 --- a/lib/honeybadger/config/defaults.rb +++ b/lib/honeybadger/config/defaults.rb @@ -91,9 +91,14 @@ class Boolean; end default: 100, type: Integer }, + :'events.max_queue_size' => { + description: 'Maximum number of event for the event worker queue.', + default: 10000, + type: Integer + }, :'events.batch_size' => { description: 'Send events batch if n events have accumulated', - default: 100, + default: 1000, type: Integer }, :'events.timeout' => { @@ -101,6 +106,16 @@ class Boolean; end default: 30_000, type: Integer }, + :'events.attach_hostname' => { + description: 'Add the hostname to all event paylaods.', + default: true, + type: Boolean + }, + :'events.ignore' => { + description: 'A list of events to ignore. Use a string to specify exact matches, or regex for more flexibility.', + default: [], + type: Array + }, plugins: { description: 'An optional list of plugins to load. Default is to load all plugins.', default: nil, @@ -311,6 +326,36 @@ class Boolean; end default: true, type: Boolean }, + :'sidekiq.insights.cluster_collection' => { + description: 'Collect cluster based metrics for Sidekiq.', + default: true, + type: Boolean + }, + :'sidekiq.insights.collection_interval' => { + description: 'The frequency in which Sidekiq cluster metrics are sampled.', + default: 60, + type: Integer + }, + :'solid_queue.insights.cluster_collection' => { + description: 'Collect cluster based metrics for SolidQueue.', + default: true, + type: Boolean + }, + :'solid_queue.insights.collection_interval' => { + description: 'The frequency in which SolidQueue cluster metrics are sampled.', + default: 60, + type: Integer + }, + :'net_http.insights.enabled' => { + description: 'Allow automatic instrumentation of Net::HTTP requests.', + default: true, + type: Boolean + }, + :'net_http.insights.full_url' => { + description: 'Record the full request url during instrumentation.', + default: false, + type: Boolean + }, :'sinatra.enabled' => { description: 'Enable Sinatra auto-initialization.', default: true, @@ -343,6 +388,16 @@ class Boolean; end description: 'Enable/Disable automatic breadcrumbs from log messages.', default: true, type: Boolean + }, + :'insights.enabled' => { + description: "Enable/Disable Honeybadger Insights built-in instrumentation.", + default: false, + type: Boolean + }, + :'insights.registry_flush_interval' => { + description: "Number of seconds between registry flushes.", + default: 60, + type: Integer } }.freeze diff --git a/lib/honeybadger/context_manager.rb b/lib/honeybadger/context_manager.rb index 37a1d76e..dad81192 100644 --- a/lib/honeybadger/context_manager.rb +++ b/lib/honeybadger/context_manager.rb @@ -61,15 +61,24 @@ def get_rack_env @mutex.synchronize { @rack_env } end + def set_request_id(request_id) + @mutex.synchronize { @request_id = request_id } + end + + def get_request_id + @mutex.synchronize { @request_id } + end + private - attr_accessor :custom, :rack_env + attr_accessor :custom, :rack_env, :request_id def _initialize @mutex.synchronize do @global_context = nil @local_context = nil @rack_env = nil + @request_id = nil end end end diff --git a/lib/honeybadger/counter.rb b/lib/honeybadger/counter.rb new file mode 100644 index 00000000..35044cb3 --- /dev/null +++ b/lib/honeybadger/counter.rb @@ -0,0 +1,18 @@ +require 'honeybadger/metric' + +module Honeybadger + class Counter < Metric + def count(by=1) + return unless by + + @samples += 1 + + @counter ||= 0 + @counter = @counter + by + end + + def payloads + [{ counter: @counter }] + end + end +end diff --git a/lib/honeybadger/events_worker.rb b/lib/honeybadger/events_worker.rb index 2102f2d3..92cca770 100644 --- a/lib/honeybadger/events_worker.rb +++ b/lib/honeybadger/events_worker.rb @@ -44,7 +44,7 @@ def initialize(config) def push(msg) return false unless start - if queue.size >= config.max_queue_size + if queue.size >= config.events_max_queue_size warn { sprintf('Unable to send event; reached max queue size of %s.', queue.size) } return false end diff --git a/lib/honeybadger/gauge.rb b/lib/honeybadger/gauge.rb new file mode 100644 index 00000000..7113e49f --- /dev/null +++ b/lib/honeybadger/gauge.rb @@ -0,0 +1,30 @@ +require 'honeybadger/metric' + +module Honeybadger + class Gauge < Metric + def record(value) + return unless value + + @samples += 1 + + @total ||= 0 + @total = @total + value + + @min = value if @min.nil? || @min > value + @max = value if @max.nil? || @max < value + @avg = @total.to_f / @samples + @latest = value + end + + def payloads + [ + { + min: @min, + max: @max, + avg: @avg, + latest: @latest + } + ] + end + end +end diff --git a/lib/honeybadger/histogram.rb b/lib/honeybadger/histogram.rb new file mode 100644 index 00000000..be9e0464 --- /dev/null +++ b/lib/honeybadger/histogram.rb @@ -0,0 +1,32 @@ +require 'honeybadger/metric' + +module Honeybadger + class Histogram < Metric + DEFAULT_BINS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0] + INFINITY = 1e20.to_f # not quite, but pretty much + + def record(value) + return unless value + + @samples += 1 + @bin_counts ||= Hash.new(0) + @bin_counts[find_bin(value)] += 1 + end + + def find_bin(value) + bin = bins.find {|b| b >= value } + bin = INFINITY if bin.nil? + bin + end + + def bins + @attributes.fetch(:bins, DEFAULT_BINS).sort + end + + def payloads + [{ + bins: (bins + [INFINITY]).map { |bin| [bin.to_f, @bin_counts[bin]] } + }] + end + end +end diff --git a/lib/honeybadger/instrumentation.rb b/lib/honeybadger/instrumentation.rb new file mode 100644 index 00000000..c2c95de0 --- /dev/null +++ b/lib/honeybadger/instrumentation.rb @@ -0,0 +1,124 @@ +require 'honeybadger/histogram' +require 'honeybadger/timer' +require 'honeybadger/counter' +require 'honeybadger/gauge' + +module Honeybadger + # +Honeybadger::Instrumentation+ defines the API for collecting metric data from anywhere + # in an application. These class methods may be used directly, or from the Honeybadger singleton + # instance. There are three usage variations as show in the example below: + # + # @example + # + # class TicketsController < ApplicationController + # def create + # # pass a block + # Honeybadger.time('create.ticket') { Ticket.create(params[:ticket]) } + # + # # pass a lambda argument + # Honeybadger.time 'create.ticket', ->{ Ticket.create(params[:ticket]) } + # + # # pass the duration argument + # duration = timing_method { Ticket.create(params[:ticket]) } + # Honeybadger.time 'create.ticket', duration: duration + # end + # end + # + # + class Instrumentation + attr_reader :agent + + def initialize(agent) + @agent = agent + end + + def registry + agent.registry + end + + # returns two parameters, the first is the duration of the execution, and the second is + # the return value of the passed block + def monotonic_timer + start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + result = yield + finish_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + [((finish_time - start_time) * 1000).round(2), result] + end + + def time(name, *args) + attributes = extract_attributes(args) + callable = extract_callable(args) + duration = attributes.delete(:duration) + + if callable + duration = monotonic_timer{ callable.call }[0] + elsif block_given? + duration = monotonic_timer{ yield }[0] + end + + raise 'No duration found' if duration.nil? + + Honeybadger::Timer.register(registry, name, attributes).tap do |timer| + timer.record(duration) + end + end + + def histogram(name, *args) + attributes = extract_attributes(args) + callable = extract_callable(args) + duration = attributes.delete(:duration) + + if callable + duration = monotonic_timer{ callable.call }[0] + elsif block_given? + duration = monotonic_timer{ yield }[0] + end + + raise 'No duration found' if duration.nil? + + Honeybadger::Histogram.register(registry, name, attributes).tap do |histogram| + histogram.record(duration) + end + end + + def increment_counter(name, *args) + attributes = extract_attributes(args) + by = extract_callable(args)&.call || attributes.delete(:by) || 1 + by = yield if block_given? + + Honeybadger::Counter.register(registry, name, attributes).tap do |counter| + counter.count(by) + end + end + + def decrement_counter(name, *args) + attributes = extract_attributes(args) + by = extract_callable(args)&.call || attributes.delete(:by) || 1 + by = yield if block_given? + + Honeybadger::Counter.register(registry, name, attributes).tap do |counter| + counter.count(by * -1) + end + end + + def gauge(name, *args) + attributes = extract_attributes(args) + value = extract_callable(args)&.call || attributes.delete(:value) + value = yield if block_given? + + Honeybadger::Gauge.register(registry, name, attributes).tap do |gauge| + gauge.record(value) + end + end + + # @api private + def extract_attributes(args) + args.select { |a| a.is_a?(Hash) }.first || {} + end + + # @api private + def extract_callable(args) + args.select { |a| a.respond_to?(:call) }.first + end + end +end diff --git a/lib/honeybadger/instrumentation_helper.rb b/lib/honeybadger/instrumentation_helper.rb new file mode 100644 index 00000000..e2d661d1 --- /dev/null +++ b/lib/honeybadger/instrumentation_helper.rb @@ -0,0 +1,120 @@ +require 'honeybadger/instrumentation' + +module Honeybadger + # +Honeybadger::InstrumentationHelper+ is a module that can be included into any class. This module + # provides a convenient DSL around the instrumentation methods to prvoide a cleaner interface. + # There are three usage variations as show in the example below: + # + # @example + # + # class TicketsController < ApplicationController + # include Honeybadger::InstrumentationHelper + # + # def create + # metric_source 'controller' + # metric_attributes { foo: 'bar' } # These attributes get tagged to all metrics called after. + # + # # pass a block + # time('create.ticket') { Ticket.create(params[:ticket]) } + # + # # pass a lambda argument + # time 'create.ticket', ->{ Ticket.create(params[:ticket]) } + # + # # pass the duration argument + # duration = timing_method { Ticket.create(params[:ticket]) } + # time 'create.ticket', duration: duration + # end + # end + # + # + module InstrumentationHelper + + # returns two parameters, the first is the duration of the execution, and the second is + # the return value of the passed block + def monotonic_timer + metric_instrumentation.monotonic_timer { yield } + end + + def metric_source(source) + @metric_source = source + end + + def metric_agent(agent) + @metric_agent = agent + end + + def metric_instrumentation + @metric_instrumentation ||= @metric_agent ? Honeybadger::Instrumentation.new(@metric_agent) : Honeybadger.instrumentation + end + + def metric_attributes(attributes) + raise "metric_attributes expects a hash" unless attributes.is_a?(Hash) + @metric_attributes = attributes + end + + def time(name, *args) + attributes = extract_attributes(args) + callable = extract_callable(args) + if callable + metric_instrumentation.time(name, attributes, ->{ callable.call }) + elsif block_given? + metric_instrumentation.time(name, attributes, ->{ yield }) + elsif attributes.keys.include?(:duration) + metric_instrumentation.time(name, attributes) + end + end + + def histogram(name, *args) + attributes = extract_attributes(args) + callable = extract_callable(args) + if callable + metric_instrumentation.histogram(name, attributes, ->{ callable.call }) + elsif block_given? + metric_instrumentation.histogram(name, attributes, ->{ yield }) + elsif attributes.keys.include?(:duration) + metric_instrumentation.histogram(name, attributes) + end + end + + def increment_counter(name, *args) + attributes = extract_attributes(args) + by = extract_callable(args)&.call || attributes.delete(:by) || 1 + if block_given? + metric_instrumentation.increment_counter(name, attributes, ->{ yield }) + else + metric_instrumentation.increment_counter(name, attributes.merge(by: by)) + end + end + + def decrement_counter(name, *args) + attributes = extract_attributes(args) + by = extract_callable(args)&.call || attributes.delete(:by) || 1 + if block_given? + metric_instrumentation.decrement_counter(name, attributes, ->{ yield }) + else + metric_instrumentation.decrement_counter(name, attributes.merge(by: by)) + end + end + + def gauge(name, *args) + attributes = extract_attributes(args) + value = extract_callable(args)&.call || attributes.delete(:value) + if block_given? + metric_instrumentation.gauge(name, attributes, ->{ yield }) + else + metric_instrumentation.gauge(name, attributes.merge(value: value)) + end + end + + # @api private + def extract_attributes(args) + attributes = metric_instrumentation.extract_attributes(args) + attributes.merge(metric_source: @metric_source).merge(@metric_attributes || {}).compact + end + + # @api private + def extract_callable(args) + metric_instrumentation.extract_callable(args) + end + end +end diff --git a/lib/honeybadger/metric.rb b/lib/honeybadger/metric.rb new file mode 100644 index 00000000..11801e96 --- /dev/null +++ b/lib/honeybadger/metric.rb @@ -0,0 +1,47 @@ +module Honeybadger + class Metric + attr_reader :name, :attributes, :samples + + def self.metric_type + name.split('::').last.downcase + end + + def self.signature(metric_type, name, attributes) + Digest::SHA1.hexdigest("#{metric_type}-#{name}-#{attributes.keys.join('-')}-#{attributes.values.join('-')}").to_sym + end + + def self.register(registry, metric_name, attributes) + registry.get(metric_type, metric_name, attributes) || + registry.register(new(metric_name, attributes)) + end + + def initialize(name, attributes) + @name = name + @attributes = attributes || {} + @samples = 0 + end + + def metric_type + self.class.metric_type + end + + def signature + self.class.signature(metric_type, name, attributes) + end + + def base_payload + attributes.merge({ + event_type: "metric.hb", + metric_name: name, + metric_type: metric_type, + samples: samples + }) + end + + def event_payloads + payloads.map do |payload| + base_payload.merge(payload) + end + end + end +end diff --git a/lib/honeybadger/metrics_worker.rb b/lib/honeybadger/metrics_worker.rb new file mode 100644 index 00000000..1241ed37 --- /dev/null +++ b/lib/honeybadger/metrics_worker.rb @@ -0,0 +1,175 @@ +require 'honeybadger/logging' + +module Honeybadger + # A concurrent queue to execute plugin collect blocks and registry. + # @api private + class MetricsWorker + extend Forwardable + + include Honeybadger::Logging::Helper + + # Sub-class thread so we have a named thread (useful for debugging in Thread.list). + class Thread < ::Thread; end + + # Used to signal the worker to shutdown. + SHUTDOWN = :__hb_worker_shutdown! + + def initialize(config) + @config = config + @interval_seconds = 1 + @mutex = Mutex.new + @marker = ConditionVariable.new + @queue = Queue.new + @shutdown = false + @start_at = nil + @pid = Process.pid + end + + def push(msg) + return false unless config.insights_enabled? + return false unless start + + queue.push(msg) + end + + def send_now(msg) + return if msg.tick > 0 + + msg.call + msg.reset + end + + def shutdown(force = false) + d { 'shutting down worker' } + + mutex.synchronize do + @shutdown = true + end + + return true if force + return true unless thread&.alive? + + queue.push(SHUTDOWN) + !!thread.join + ensure + queue.clear + kill! + end + + # Blocks until queue is processed up to this point in time. + def flush + mutex.synchronize do + if thread && thread.alive? + queue.push(marker) + marker.wait(mutex) + end + end + end + + def start + return false unless can_start? + + mutex.synchronize do + @shutdown = false + @start_at = nil + + return true if thread&.alive? + + @pid = Process.pid + @thread = Thread.new { run } + end + + true + end + + private + + attr_reader :config, :queue, :pid, :mutex, :marker, :thread, :interval_seconds, :start_at + + def shutdown? + mutex.synchronize { @shutdown } + end + + def suspended? + mutex.synchronize { start_at && Time.now.to_i < start_at } + end + + def can_start? + return false if shutdown? + return false if suspended? + true + end + + def kill! + d { 'killing worker thread' } + + if thread + Thread.kill(thread) + thread.join # Allow ensure blocks to execute. + end + + true + end + + def suspend(interval) + mutex.synchronize do + @start_at = Time.now.to_i + interval + queue.clear + end + + # Must be performed last since this may kill the current thread. + kill! + end + + def run + begin + d { 'worker started' } + loop do + case msg = queue.pop + when SHUTDOWN then break + when ConditionVariable then signal_marker(msg) + else work(msg) + end + end + ensure + d { 'stopping worker' } + end + rescue Exception => e + error { + msg = "Error in worker thread (shutting down) class=%s message=%s\n\t%s" + sprintf(msg, e.class, e.message.dump, Array(e.backtrace).join("\n\t")) + } + ensure + release_marker + end + + def work(msg) + send_now(msg) + + if shutdown? + kill! + return + end + rescue StandardError => e + error { + err = "Error in worker thread class=%s message=%s\n\t%s" + sprintf(err, e.class, e.message.dump, Array(e.backtrace).join("\n\t")) + } + ensure + queue.push(msg) unless shutdown? || suspended? + sleep(interval_seconds) + end + + # Release the marker. Important to perform during cleanup when shutting + # down, otherwise it could end up waiting indefinitely. + def release_marker + signal_marker(marker) + end + + def signal_marker(marker) + mutex.synchronize do + marker.signal + end + end + end +end diff --git a/lib/honeybadger/notification_subscriber.rb b/lib/honeybadger/notification_subscriber.rb new file mode 100644 index 00000000..183c06f2 --- /dev/null +++ b/lib/honeybadger/notification_subscriber.rb @@ -0,0 +1,99 @@ +require 'honeybadger/instrumentation_helper' + +module Honeybadger + class NotificationSubscriber + def start(name, id, payload) + @start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + end + + def finish(name, id, payload) + @finish_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + + return unless process?(name) + + payload = { + instrumenter_id: id, + duration: ((@finish_time - @start_time) * 1000).round(2) + }.merge(format_payload(payload).compact) + + record(name, payload) + end + + def record(name, payload) + Honeybadger.event(name, payload) + end + + def process?(event) + true + end + + def format_payload(payload) + payload + end + end + + class ActionControllerSubscriber < NotificationSubscriber + def format_payload(payload) + payload.except(:headers, :request, :response) + end + end + + class ActionControllerCacheSubscriber < NotificationSubscriber + end + + class ActiveSupportCacheSubscriber < NotificationSubscriber + end + + class ActionViewSubscriber < NotificationSubscriber + PROJECT_ROOT = defined?(::Rails) ? ::Rails.root.to_s : '' + + def format_payload(payload) + { + view: payload[:identifier].to_s.gsub(PROJECT_ROOT, '[PROJECT_ROOT]'), + layout: payload[:layout] + } + end + end + + class ActiveRecordSubscriber < NotificationSubscriber + def format_payload(payload) + { + query: payload[:sql].to_s.gsub(/\s+/, ' ').strip, + async: payload[:async] + } + end + end + + class ActiveJobSubscriber < NotificationSubscriber + def format_payload(payload) + job = payload[:job] + payload.except(:job).merge({ + job_class: job.class, + job_id: job.job_id, + queue_name: job.queue_name + }) + end + end + + class ActiveJobMetricsSubscriber < NotificationSubscriber + include Honeybadger::InstrumentationHelper + + def format_payload(payload) + { + job_class: payload[:job].class, + queue_name: payload[:job].queue_name + } + end + + def record(name, payload) + metric_source 'active_job' + histogram name, { bins: [30, 60, 120, 300, 1800, 3600, 21_600] }.merge(payload) + end + end + + class ActionMailerSubscriber < NotificationSubscriber + end + + class ActiveStorageSubscriber < NotificationSubscriber + end +end diff --git a/lib/honeybadger/plugin.rb b/lib/honeybadger/plugin.rb index b61874f3..ae3e96b8 100644 --- a/lib/honeybadger/plugin.rb +++ b/lib/honeybadger/plugin.rb @@ -1,4 +1,5 @@ require 'forwardable' +require 'honeybadger/instrumentation_helper' module Honeybadger # +Honeybadger::Plugin+ defines the API for registering plugins with @@ -7,6 +8,11 @@ module Honeybadger # optional dependencies and load the plugin for each dependency only if it's # present in the application. # + # Plugins may also define a collect block that is repeatedly called from + # within a thread. The MetricsWorker contains a loop that will call all + # enabled plugins' collect method, and then sleep for 1 second. This block + # is useful for collecting and/or sending metrics at regular intervals. + # # See the plugins/ directory for examples of official plugins. If you're # interested in developing a plugin for Honeybadger, see the Integration # Guide: https://docs.honeybadger.io/ruby/gem-reference/integration.html @@ -37,6 +43,14 @@ module Honeybadger # Honeybadger.notify(exception) # end # end + # + # collect do + # # This block will be periodically called at regular intervals. Here you can + # # gather metrics or inspect services. See the Honeybadger::InstrumentationHelper + # # module to see availble methods for metric collection. + # gauge 'scheduled_jobs', -> { MyFramework.stats.scheduled_jobs.count } + # gauge 'latency', -> { MyFramework.stats.latency } + # end # end # end # end @@ -53,13 +67,15 @@ def instances @@instances end - # Register a new plugin with Honeybadger. See {#requirement} and {#execution}. + # Register a new plugin with Honeybadger. See {#requirement}, {#execution}, and + # {#collect}.. # # @example # # Honeybadger::Plugin.register 'my_framework' do # requirement { } # execution { } + # collect { } # end # # @param [String, Symbol] name The optional name of the plugin. Should use @@ -111,12 +127,41 @@ def call def_delegator :@config, :logger end + # @api private + class CollectorExecution < Execution + include Honeybadger::InstrumentationHelper + + DEFAULT_COLLECTION_INTERVAL = 60 + + def initialize(name, config, options, &block) + @name = name + @config = config + @options = options + @block = block + @interval = config.collection_interval(name) || options.fetch(:interval, DEFAULT_COLLECTION_INTERVAL) + @end_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + @interval + end + + def tick + @end_time - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + end + + def reset + @end_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + @interval + end + + def register! + Honeybadger.collect(self) + end + end + # @api private def initialize(name) @name = name @loaded = false @requirements = [] @executions = [] + @collectors = [] end # Define a requirement. All requirement blocks must return +true+ for the @@ -165,6 +210,31 @@ def execution(&block) @executions << block end + # Define an collect block. Collect blocks will be added to an execution + # queue if requirement blocks return +true+. The block will be called as frequently + # as once per second, but can be configured to increase it's interval. + # + # @example + # + # Honeybadger::Plugin.register 'my_framework' do + # requirement { defined?(MyFramework) } + # + # collect do + # stats = MyFramework.stats + # gauge 'capacity', -> { stats.capcity } + # end + # + # collect(interval: 10) do + # stats = MyFramework.more_expensive_stats + # gauge 'other_stat', -> { stats.expensive_metric } + # end + # end + # + # @return nil + def collect(options={}, &block) + @collectors << [options, block] + end + # @api private def ok?(config) @requirements.all? {|r| Execution.new(config, &r).call } @@ -181,6 +251,7 @@ def load!(config) elsif ok?(config) config.logger.debug(sprintf('load plugin name=%s', name)) @executions.each {|e| Execution.new(config, &e).call } + @collectors.each {|o,b| CollectorExecution.new(name, config, o, &b).register! } @loaded = true else config.logger.debug(sprintf('skip plugin name=%s reason=requirement', name)) @@ -193,6 +264,11 @@ def load!(config) false end + # @api private + def collectors + @collectors + end + # @api private def loaded? @loaded diff --git a/lib/honeybadger/plugins/active_job.rb b/lib/honeybadger/plugins/active_job.rb index d7347c0a..8c7d872b 100644 --- a/lib/honeybadger/plugins/active_job.rb +++ b/lib/honeybadger/plugins/active_job.rb @@ -1,3 +1,5 @@ +require 'honeybadger/notification_subscriber' + module Honeybadger module Plugins module ActiveJob @@ -33,7 +35,7 @@ def context(job) # rubocop:disable Metrics/MethodLength end end - Plugin.register do + Plugin.register :active_job do requirement do defined?(::Rails.application) && ::Rails.application.config.respond_to?(:active_job) && @@ -44,6 +46,11 @@ def context(job) # rubocop:disable Metrics/MethodLength execution do ::ActiveJob::Base.set_callback(:perform, :around, &ActiveJob.method(:perform_around)) + + if config.load_plugin_insights?(:active_job) + ::ActiveSupport::Notifications.subscribe(/(enqueue_at|enqueue|enqueue_retry|enqueue_all|perform|retry_stopped|discard)\.active_job/, Honeybadger::ActiveJobSubscriber.new) + ::ActiveSupport::Notifications.subscribe('perform.active_job', Honeybadger::ActiveJobMetricsSubscriber.new) + end end end end diff --git a/lib/honeybadger/plugins/autotuner.rb b/lib/honeybadger/plugins/autotuner.rb new file mode 100644 index 00000000..5f8adf6d --- /dev/null +++ b/lib/honeybadger/plugins/autotuner.rb @@ -0,0 +1,30 @@ +require 'honeybadger/instrumentation_helper' +require 'honeybadger/plugin' + +module Honeybadger + module Plugins + module Autotuner + Plugin.register :autotuner do + requirement { config.load_plugin_insights?(:autotuner) && defined?(::Autotuner) } + + execution do + singleton_class.include(Honeybadger::InstrumentationHelper) + + ::Autotuner.enabled = true + + ::Autotuner.reporter = proc do |report| + Honeybadger.event("report.autotuner", report: report.to_s) + end + + metric_source 'autotuner' + + ::Autotuner.metrics_reporter = proc do |metrics| + metrics.each do |key, val| + gauge key, ->{ val } + end + end + end + end + end + end +end diff --git a/lib/honeybadger/plugins/karafka.rb b/lib/honeybadger/plugins/karafka.rb index 91dd0020..88e4b8d6 100644 --- a/lib/honeybadger/plugins/karafka.rb +++ b/lib/honeybadger/plugins/karafka.rb @@ -1,14 +1,29 @@ require 'honeybadger/plugin' -require 'honeybadger/ruby' module Honeybadger module Plugins - Plugin.register do + Plugin.register :karafka do requirement { defined?(::Karafka) } execution do ::Karafka.monitor.subscribe('error.occurred') do |event| Honeybadger.notify(event[:error]) + Honeybadger.event('error.occurred', error: event[:error]) if config.load_plugin_insights?(:karafka) + end + + if config.load_plugin_insights?(:karafka) + ::Karafka.monitor.subscribe("consumer.consumed") do |event| + context = { + duration: event.payload[:time], + consumer: event.payload[:caller].class.to_s, + id: event.payload[:caller].id, + topic: event.payload[:caller].messages.metadata.topic, + messages_count: event.payload[:caller].messages.metadata.size, + partition: event.payload[:caller].messages.metadata.partition + } + + Honeybadger.event('consumer.consumed.karafka', context) + end end end end diff --git a/lib/honeybadger/plugins/net_http.rb b/lib/honeybadger/plugins/net_http.rb new file mode 100644 index 00000000..80836404 --- /dev/null +++ b/lib/honeybadger/plugins/net_http.rb @@ -0,0 +1,52 @@ +require 'net/http' +require 'honeybadger/plugin' +require 'honeybadger/instrumentation' +require 'resolv' + +module Honeybadger + module Plugins + module Net + module HTTP + def request(request_data, body = nil, &block) + return super unless started? + return super if hb? + + Honeybadger.instrumentation.monotonic_timer { super }.tap do |duration, response_data| + context = { + duration: duration, + method: request_data.method, + status: response_data.code.to_i + }.merge(parsed_uri_data(request_data)) + + Honeybadger.event('request.net_http', context) + end[1] # return the response data only + end + + def hb? + address.to_s[/#{Honeybadger.config[:'connection.host'].to_s}/] + end + + def parsed_uri_data(request_data) + uri = request_data.uri || build_uri(request_data) + {}.tap do |uri_data| + uri_data[:host] = uri.host + uri_data[:url] = uri.to_s if Honeybadger.config[:'net_http.insights.full_url'] + end + end + + def build_uri(request_data) + hostname = (address[/#{Resolv::IPv6::Regex}/]) ? "[#{address}]" : address + URI.parse("#{use_ssl? ? 'https' : 'http'}://#{hostname}#{request_data.path}") + end + + Plugin.register :net_http do + requirement { config.load_plugin_insights?(:net_http) } + + execution do + ::Net::HTTP.send(:prepend, Honeybadger::Plugins::Net::HTTP) + end + end + end + end + end +end diff --git a/lib/honeybadger/plugins/rails.rb b/lib/honeybadger/plugins/rails.rb index af1c1972..13854e16 100644 --- a/lib/honeybadger/plugins/rails.rb +++ b/lib/honeybadger/plugins/rails.rb @@ -1,4 +1,5 @@ require 'honeybadger/plugin' +require 'honeybadger/notification_subscriber' module Honeybadger module Plugins @@ -68,6 +69,20 @@ def self.source_ignored?(source) end end end + + Plugin.register :rails do + requirement { config.load_plugin_insights?(:rails_metrics) && defined?(::Rails.application) && ::Rails.application } + + execution do + ::ActiveSupport::Notifications.subscribe(/(process_action|send_file|redirect_to|halted_callback|unpermitted_parameters)\.action_controller/, Honeybadger::ActionControllerSubscriber.new) + ::ActiveSupport::Notifications.subscribe(/(write_fragment|read_fragment|expire_fragment|exist_fragment\?)\.action_controller/, Honeybadger::ActionControllerCacheSubscriber.new) + ::ActiveSupport::Notifications.subscribe(/cache_(read|read_multi|generate|fetch_hit|write|write_multi|increment|decrement|delete|delete_multi|cleanup|prune|exist\?)\.active_support/, Honeybadger::ActiveSupportCacheSubscriber.new) + ::ActiveSupport::Notifications.subscribe(/^render_(template|partial|collection)\.action_view/, Honeybadger::ActionViewSubscriber.new) + ::ActiveSupport::Notifications.subscribe("sql.active_record", Honeybadger::ActiveRecordSubscriber.new) + ::ActiveSupport::Notifications.subscribe("process.action_mailer", Honeybadger::ActionMailerSubscriber.new) + ::ActiveSupport::Notifications.subscribe(/(service_upload|service_download)\.active_storage/, Honeybadger::ActiveStorageSubscriber.new) + end + end end end end diff --git a/lib/honeybadger/plugins/sidekiq.rb b/lib/honeybadger/plugins/sidekiq.rb index eae907b1..828fbeea 100644 --- a/lib/honeybadger/plugins/sidekiq.rb +++ b/lib/honeybadger/plugins/sidekiq.rb @@ -1,3 +1,4 @@ +require 'honeybadger/instrumentation_helper' require 'honeybadger/plugin' require 'honeybadger/ruby' @@ -11,7 +12,58 @@ def call(_worker, _msg, _queue) end end - Plugin.register do + class ServerMiddlewareInstrumentation + include Honeybadger::InstrumentationHelper + + def call(worker, msg, queue, &block) + if msg["wrapped"] + context = { + jid: msg["jid"], + worker: msg["wrapped"], + queue: queue + } + else + context = { + jid: msg["jid"], + worker: msg["class"], + queue: queue + } + end + + begin + duration = Honeybadger.instrumentation.monotonic_timer { block.call }[0] + status = 'success' + rescue Exception => e + status = 'failure' + raise + ensure + context.merge!(duration: duration, status: status) + Honeybadger.event('perform.sidekiq', context) + + metric_source 'sidekiq' + histogram 'perform', { bins: [30, 60, 120, 300, 1800, 3600, 21_600] }.merge(context.slice(:worker, :queue, :duration)) + end + end + end + + class ClientMiddlewareInstrumentation + include Honeybadger::InstrumentationHelper + + def call(worker, msg, queue, _redis) + context = { + worker: msg["wrapped"] || msg["class"], + queue: queue + } + + Honeybadger.event('enqueue.sidekiq', context) + + yield + end + end + + Plugin.register :sidekiq do + leader_checker = nil + requirement { defined?(::Sidekiq) } execution do @@ -71,6 +123,81 @@ def call(_worker, _msg, _queue) } end end + + if config.load_plugin_insights?(:sidekiq) + require "sidekiq" + require "sidekiq/api" + require "sidekiq/component" + + class SidekiqClusterCollectionChecker + include ::Sidekiq::Component + def initialize(config) + @config = config + end + + def collect? + return true unless defined?(::Sidekiq::Enterprise) + leader? + end + end + + ::Sidekiq.configure_server do |config| + config.server_middleware { |chain| chain.add(ServerMiddlewareInstrumentation) } + config.client_middleware { |chain| chain.add(ClientMiddlewareInstrumentation) } + config.on(:startup) do + leader_checker = SidekiqClusterCollectionChecker.new(config) + end + end + + ::Sidekiq.configure_client do |config| + config.client_middleware { |chain| chain.add(ClientMiddlewareInstrumentation) } + end + end + end + + collect do + if config.cluster_collection?(:sidekiq) && (leader_checker.nil? || leader_checker.collect?) + metric_source 'sidekiq' + + stats = ::Sidekiq::Stats.new + + gauge 'active_workers', ->{ stats.workers_size } + gauge 'active_processes', ->{ stats.processes_size } + gauge 'jobs_processed', ->{ stats.processed } + gauge 'jobs_failed', ->{ stats.failed } + gauge 'jobs_scheduled', ->{ stats.scheduled_size } + gauge 'jobs_enqueued', ->{ stats.enqueued } + gauge 'jobs_dead', ->{ stats.dead_size } + gauge 'jobs_retry', ->{ stats.retry_size } + + ::Sidekiq::Queue.all.each do |queue| + gauge 'queue_latency', { queue: queue.name }, ->{ (queue.latency * 1000).ceil } + gauge 'queue_depth', { queue: queue.name }, ->{ queue.size } + end + + Hash.new(0).tap do |busy_counts| + ::Sidekiq::Workers.new.each do |_pid, _tid, work| + payload = work.respond_to?(:payload) ? work.payload : work["payload"] + payload = JSON.parse(payload) if payload.is_a?(String) + busy_counts[payload["queue"]] += 1 + end + end.each do |queue_name, busy_count| + gauge 'queue_busy', { queue: queue_name }, ->{ busy_count } + end + + processes = ::Sidekiq::ProcessSet.new.to_enum(:each).to_a + gauge 'capacity', ->{ processes.map { |process| process["concurrency"] }.sum } + + process_utilizations = processes.map do |process| + next unless process["concurrency"].to_f > 0 + process["busy"] / process["concurrency"].to_f + end.compact + + if process_utilizations.any? + utilization = process_utilizations.sum / process_utilizations.length.to_f + gauge 'utilization', ->{ utilization } + end + end end end end diff --git a/lib/honeybadger/plugins/solid_queue.rb b/lib/honeybadger/plugins/solid_queue.rb new file mode 100644 index 00000000..f1810f1f --- /dev/null +++ b/lib/honeybadger/plugins/solid_queue.rb @@ -0,0 +1,27 @@ +module Honeybadger + module Plugins + module SolidQueue + Plugin.register :solid_queue do + requirement { defined?(::SolidQueue) } + + collect do + if config.cluster_collection?(:solid_queue) + metric_source 'solid_queue' + + gauge 'jobs_in_progress', ->{ ::SolidQueue::ClaimedExecution.count } + gauge 'jobs_blocked', ->{ ::SolidQueue::BlockedExecution.count } + gauge 'jobs_failed', ->{ ::SolidQueue::FailedExecution.count } + gauge 'jobs_scheduled', ->{ ::SolidQueue::ScheduledExecution.count } + gauge 'jobs_processed', ->{ ::SolidQueue::Job.where.not(finished_at: nil).count } + gauge 'active_workers', ->{ ::SolidQueue::Process.where(kind: "Worker").count } + gauge 'active_dispatchers', ->{ ::SolidQueue::Process.where(kind: "Dispatcher").count } + + ::SolidQueue::Queue.all.each do |queue| + gauge 'queue_depth', { queue: queue.name }, ->{ queue.size } + end + end + end + end + end + end +end diff --git a/lib/honeybadger/plugins/system.rb b/lib/honeybadger/plugins/system.rb new file mode 100644 index 00000000..2202b0ab --- /dev/null +++ b/lib/honeybadger/plugins/system.rb @@ -0,0 +1,16 @@ +require 'honeybadger/util/stats' +require 'honeybadger/plugin' + +module Honeybadger + module Plugins + module System + Plugin.register :system do + requirement { Util::Stats::HAS_MEM || Util::Stats::HAS_LOAD } + + collect do + Honeybadger.event('report.system', Util::Stats.all) + end + end + end + end +end diff --git a/lib/honeybadger/registry.rb b/lib/honeybadger/registry.rb new file mode 100644 index 00000000..77322258 --- /dev/null +++ b/lib/honeybadger/registry.rb @@ -0,0 +1,32 @@ +module Honeybadger + class Registry + def initialize + @mutex = Mutex.new + @metrics = Hash.new + end + + def register(metric) + @mutex.synchronize do + @metrics[metric.signature] = metric + end + end + + def get(metric_type, name, attributes) + @mutex.synchronize do + @metrics[Honeybadger::Metric.signature(metric_type, name, attributes)] + end + end + + def flush + @mutex.synchronize do + @metrics = Hash.new + end + end + + def metrics + @mutex.synchronize do + @metrics.values + end + end + end +end diff --git a/lib/honeybadger/registry_execution.rb b/lib/honeybadger/registry_execution.rb new file mode 100644 index 00000000..dbd8a4cd --- /dev/null +++ b/lib/honeybadger/registry_execution.rb @@ -0,0 +1,28 @@ +module Honeybadger + class RegistryExecution + def initialize(registry, config, options) + @registry = registry + @config = config + @options = options + @interval = config[:'insights.registry_flush_interval'] || options.fetch(:interval, 60) + @end_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + @interval + end + + def tick + @end_time - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + end + + def reset + @end_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + @interval + @registry.flush + end + + def call + @registry.metrics.each do |metric| + metric.event_payloads.each do |payload| + Honeybadger.event(payload.merge(interval: @interval)) + end + end + end + end +end diff --git a/lib/honeybadger/singleton.rb b/lib/honeybadger/singleton.rb index 53016ad3..0b8f0ee2 100644 --- a/lib/honeybadger/singleton.rb +++ b/lib/honeybadger/singleton.rb @@ -39,6 +39,14 @@ module Honeybadger def_delegator :'Honeybadger::Agent.instance', :clear! def_delegator :'Honeybadger::Agent.instance', :track_deployment def_delegator :'Honeybadger::Agent.instance', :event + def_delegator :'Honeybadger::Agent.instance', :collect + def_delegator :'Honeybadger::Agent.instance', :registry + def_delegator :'Honeybadger::Agent.instance', :instrumentation + def_delegator :'Honeybadger::Agent.instance', :time + def_delegator :'Honeybadger::Agent.instance', :histogram + def_delegator :'Honeybadger::Agent.instance', :gauge + def_delegator :'Honeybadger::Agent.instance', :increment_counter + def_delegator :'Honeybadger::Agent.instance', :decrement_counter # @!macro [attach] def_delegator # @!method $2(...) diff --git a/lib/honeybadger/timer.rb b/lib/honeybadger/timer.rb new file mode 100644 index 00000000..d8b956b8 --- /dev/null +++ b/lib/honeybadger/timer.rb @@ -0,0 +1,6 @@ +require 'honeybadger/gauge' + +module Honeybadger + class Timer < Gauge + end +end diff --git a/lib/puma/plugin/honeybadger.rb b/lib/puma/plugin/honeybadger.rb new file mode 100644 index 00000000..0265636b --- /dev/null +++ b/lib/puma/plugin/honeybadger.rb @@ -0,0 +1,43 @@ +require 'honeybadger/instrumentation_helper' + +module Honeybadger + class PumaPlugin + include Honeybadger::InstrumentationHelper + + STATS_KEYS = %i(pool_capacity max_threads requests_count backlog running).freeze + + ::Puma::Plugin.create do + def start(launcher) + puma_plugin = ::Honeybadger::PumaPlugin.new + in_background do + loop do + puma_plugin.record + sleep 1 + end + end + end + end + + def record + metric_source 'puma' + + stats = ::Puma.stats rescue {} + stats = stats.is_a?(Hash) ? stats : JSON.parse(stats, symbolize_names: true) + + if stats[:worker_status].is_a?(Array) + stats[:worker_status].each do |worker_data| + context = { worker: worker_data[:index] } + record_puma_stats(worker_data[:last_status], context) + end + else + record_puma_stats(stats) + end + end + + def record_puma_stats(stats, context={}) + STATS_KEYS.each do |stat| + gauge stat, context, ->{ stats[stat] } if stats[stat] + end + end + end +end diff --git a/spec/unit/honeybadger/agent_spec.rb b/spec/unit/honeybadger/agent_spec.rb index f604be18..ccfa8b30 100644 --- a/spec/unit/honeybadger/agent_spec.rb +++ b/spec/unit/honeybadger/agent_spec.rb @@ -336,6 +336,79 @@ subject.event(event_type: "test_event", some_data: "is here") end end + + describe "ignoring events" do + let(:config) { Honeybadger::Config.new(api_key:'fake api key', logger: NULL_LOGGER, backend: :debug, :'events.ignore' => ignored_events) } + + after { subject.event(event_type: event_type, some_data: "is here") } + + context "when configured with an event type matching string" do + let(:ignored_events) { ["report.system"] } + let(:event_type) { "report.system" } + + it "does not push an event" do + expect(events_worker).not_to receive(:push) + end + end + + context "when configured with an event type non-matching string" do + let(:ignored_events) { ["non-matching"] } + let(:event_type) { "report.system" } + + it "does push an event" do + expect(events_worker).to receive(:push) + end + end + + context "when configured with an event type matching regex" do + let(:ignored_events) { [/.*\.system/] } + let(:event_type) { "report.system" } + + it "does not push an event" do + expect(events_worker).not_to receive(:push) + end + end + + context "when configured with an event type non-matching regex" do + let(:ignored_events) { [/.*\.foo/] } + let(:event_type) { "report.system" } + + it "does push an event" do + expect(events_worker).to receive(:push) + end + end + + context "when event type is nil" do + let(:ignored_events) { [/test/] } + let(:event_type) { nil } + + it "does push an event" do + expect(events_worker).to receive(:push) + end + end + end + end + + context "#collect" do + let(:config) { Honeybadger::Config.new(api_key:'fake api key', logger: NULL_LOGGER, debug: true, :'insights.enabled' => true) } + let(:metrics_worker) { double(Honeybadger::MetricsWorker.new(config)) } + let(:instance) { Honeybadger::Agent.new(config) } + let(:collection_execution) { double(Honeybadger::Plugin::CollectorExecution) } + + subject { instance } + + before do + allow(instance).to receive(:metrics_worker).and_return(metrics_worker) + end + + context "with a collection execution instance" do + it "adds to the worker" do + expect(metrics_worker).to receive(:push) do |msg| + expect(msg).to eq(collection_execution) + end + subject.collect(collection_execution) + end + end end context do diff --git a/spec/unit/honeybadger/counter_spec.rb b/spec/unit/honeybadger/counter_spec.rb new file mode 100644 index 00000000..4da351ad --- /dev/null +++ b/spec/unit/honeybadger/counter_spec.rb @@ -0,0 +1,15 @@ +# encoding: utf-8 + +describe Honeybadger::Counter do + describe "#payloads" do + let(:metric) { described_class.new(name, attributes) } + let(:name) { "perform" } + let(:attributes) { { foo: "bar" } } + + subject { metric.payloads } + + before { metric.count } + + it { should eq [{ counter: 1 }] } + end +end diff --git a/spec/unit/honeybadger/events_worker_spec.rb b/spec/unit/honeybadger/events_worker_spec.rb index 0b67059f..05f4ad7d 100644 --- a/spec/unit/honeybadger/events_worker_spec.rb +++ b/spec/unit/honeybadger/events_worker_spec.rb @@ -117,7 +117,7 @@ def flush context "when queue is full" do before do - allow(config).to receive(:max_queue_size).and_return(5) + allow(config).to receive(:events_max_queue_size).and_return(5) allow(instance).to receive(:queue).and_return(double(size: 5)) end diff --git a/spec/unit/honeybadger/gauge_spec.rb b/spec/unit/honeybadger/gauge_spec.rb new file mode 100644 index 00000000..b6767f17 --- /dev/null +++ b/spec/unit/honeybadger/gauge_spec.rb @@ -0,0 +1,15 @@ +# encoding: utf-8 + +describe Honeybadger::Gauge do + describe "#payloads" do + let(:metric) { described_class.new(name, attributes) } + let(:name) { "perform" } + let(:attributes) { { foo: "bar" } } + + subject { metric.payloads } + + before { metric.record(1) } + + it { should eq [{ avg: 1.0, latest: 1, max: 1, min: 1 }] } + end +end diff --git a/spec/unit/honeybadger/histogram_spec.rb b/spec/unit/honeybadger/histogram_spec.rb new file mode 100644 index 00000000..ba0a0a39 --- /dev/null +++ b/spec/unit/honeybadger/histogram_spec.rb @@ -0,0 +1,15 @@ +# encoding: utf-8 + +describe Honeybadger::Histogram do + describe "#payloads" do + let(:metric) { described_class.new(name, attributes) } + let(:name) { "perform" } + let(:attributes) { { foo: "bar" } } + + subject { metric.payloads } + + before { metric.record(1) } + + it { should eq [{ bins: [[0.005, 0], [0.01, 0], [0.025, 0], [0.05, 0], [0.1, 0], [0.25, 0], [0.5, 0], [1.0, 1], [2.5, 0], [5.0, 0], [10.0, 0], [1.0e+20, 0]]}] } + end +end diff --git a/spec/unit/honeybadger/instrumentation_helper_spec.rb b/spec/unit/honeybadger/instrumentation_helper_spec.rb new file mode 100644 index 00000000..8f1ac4ce --- /dev/null +++ b/spec/unit/honeybadger/instrumentation_helper_spec.rb @@ -0,0 +1,206 @@ +require 'honeybadger/instrumentation_helper' + +describe Honeybadger::InstrumentationHelper do + let(:test_object) { Object.new.tap { |o| o.extend(described_class) }} + + before do + Honeybadger.registry.flush + end + + describe '#metric_source' do + let(:metric_source) { 'test-source' } + + it 'sets the metric_source attribute' do + test_object.metric_source metric_source + gauge = test_object.gauge('test_gauge', ->{ 1 }) + + expect(gauge.base_payload[:metric_source]).to eq metric_source + end + end + + describe '#metric_attributes' do + let(:metric_attributes) { { foo: 'bar' } } + + it 'merges the attributes' do + test_object.metric_attributes metric_attributes + gauge = test_object.gauge('test_gauge', ->{ 1 }) + + expect(gauge.attributes.keys).to include(:foo) + expect(gauge.attributes[:foo]).to eq 'bar' + end + end + + describe '#time' do + context 'by keyword argument' do + it 'creates a timer object' do + timer = test_object.time('test_timer', duration: 0.1) + + expect(timer).to be_a Honeybadger::Timer + expect(timer.payloads[0][:latest]).to be > 0 + end + end + + context 'by lambda' do + it 'creates a timer object' do + timer = test_object.time('test_timer', ->{ sleep(0.1) }) + + expect(timer).to be_a Honeybadger::Timer + expect(timer.payloads[0][:latest]).to be > 0 + end + end + + context 'by block' do + it 'creates a timer object' do + timer = test_object.time('test_timer') { sleep(0.1) } + + expect(timer).to be_a Honeybadger::Timer + expect(timer.payloads[0][:latest]).to be > 0 + end + end + end + + describe '#gauge' do + context 'by keyword argument' do + it 'creates a gauge object' do + gauge = test_object.gauge('test_gauge', value: 1) + test_object.gauge('test_gauge', value: 10) + + expect(gauge).to be_a Honeybadger::Gauge + expect(gauge.payloads[0][:latest]).to eq(10) + expect(gauge.payloads[0][:min]).to eq(1) + expect(gauge.payloads[0][:max]).to eq(10) + expect(gauge.payloads[0][:avg]).to eq(5.5) + end + end + + context 'by lambda' do + it 'creates a gauge object' do + gauge = test_object.gauge('test_gauge', ->{ 1 }) + test_object.gauge('test_gauge', -> { 10 }) + + expect(gauge).to be_a Honeybadger::Gauge + expect(gauge.payloads[0][:latest]).to eq(10) + expect(gauge.payloads[0][:min]).to eq(1) + expect(gauge.payloads[0][:max]).to eq(10) + expect(gauge.payloads[0][:avg]).to eq(5.5) + end + end + + context 'by block' do + it 'creates a gauge object' do + gauge = test_object.gauge('test_gauge') { 1 } + test_object.gauge('test_gauge') { 10 } + + expect(gauge).to be_a Honeybadger::Gauge + expect(gauge.payloads[0][:latest]).to eq(10) + expect(gauge.payloads[0][:min]).to eq(1) + expect(gauge.payloads[0][:max]).to eq(10) + expect(gauge.payloads[0][:avg]).to eq(5.5) + end + end + end + describe '#increment_counter' do + context 'by default increment' do + it 'creates a counter object' do + counter = test_object.increment_counter('test_counter') + + expect(counter).to be_a Honeybadger::Counter + expect(counter.payloads[0][:counter]).to eq(1) + end + end + + context 'by keyword argument' do + it 'creates a counter object' do + counter = test_object.increment_counter('test_counter', by: 1) + + expect(counter).to be_a Honeybadger::Counter + expect(counter.payloads[0][:counter]).to eq(1) + end + end + + context 'by lambda' do + it 'creates a counter object' do + counter = test_object.increment_counter('test_counter', ->{ 1 }) + + expect(counter).to be_a Honeybadger::Counter + expect(counter.payloads[0][:counter]).to eq(1) + end + end + + context 'by block' do + it 'creates a counter object' do + counter = test_object.increment_counter('test_counter') { 1 } + + expect(counter).to be_a Honeybadger::Counter + expect(counter.payloads[0][:counter]).to eq(1) + end + end + end + + describe '#decrement_counter' do + context 'by default decrement' do + it 'creates a counter object' do + counter = test_object.decrement_counter('test_counter') + + expect(counter).to be_a Honeybadger::Counter + expect(counter.payloads[0][:counter]).to eq(-1) + end + end + + context 'by keyword argument' do + it 'creates a counter object' do + counter = test_object.decrement_counter('test_counter', by: 1) + + expect(counter).to be_a Honeybadger::Counter + expect(counter.payloads[0][:counter]).to eq(-1) + end + end + + context 'by lambda' do + it 'creates a counter object' do + counter = test_object.decrement_counter('test_counter', ->{ 1 }) + + expect(counter).to be_a Honeybadger::Counter + expect(counter.payloads[0][:counter]).to eq(-1) + end + end + + context 'by block' do + it 'creates a counter object' do + counter = test_object.decrement_counter('test_counter') { 1 } + + expect(counter).to be_a Honeybadger::Counter + expect(counter.payloads[0][:counter]).to eq(-1) + end + end + end + + describe '#histogram' do + context 'by keyword argument' do + it 'creates a histogram object' do + histogram = test_object.histogram('test_histogram', duration: 0.0001) + + expect(histogram).to be_a Honeybadger::Histogram + expect(histogram.payloads[0][:bins].map { |b| b[1] }).to include 1 + end + end + + context 'by lambda' do + it 'creates a histogram object' do + histogram = test_object.histogram('test_histogram', ->{ sleep(0.0001) }) + + expect(histogram).to be_a Honeybadger::Histogram + expect(histogram.payloads[0][:bins].map { |b| b[1] }).to include 1 + end + end + + context 'by block' do + it 'creates a histogram object' do + histogram = test_object.histogram('test_histogram') { sleep(0.0001) } + + expect(histogram).to be_a Honeybadger::Histogram + expect(histogram.payloads[0][:bins].map { |b| b[1] }).to include 1 + end + end + end +end diff --git a/spec/unit/honeybadger/instrumentation_spec.rb b/spec/unit/honeybadger/instrumentation_spec.rb new file mode 100644 index 00000000..a85918ed --- /dev/null +++ b/spec/unit/honeybadger/instrumentation_spec.rb @@ -0,0 +1,185 @@ +require 'honeybadger/instrumentation' + +describe Honeybadger::Instrumentation do + let(:agent) { Honeybadger::Agent.new } + let(:instrumentation) { described_class.new(agent) } + + before do + agent.registry.flush + end + + describe '.time' do + context 'by keyword argument' do + it 'creates a timer object' do + timer = instrumentation.time('test_timer', duration: 0.1) + + expect(timer).to be_a Honeybadger::Timer + expect(timer.payloads[0][:latest]).to be > 0 + end + end + + context 'by lambda' do + it 'creates a timer object' do + timer = instrumentation.time('test_timer', ->{ sleep(0.1) }) + + expect(timer).to be_a Honeybadger::Timer + expect(timer.payloads[0][:latest]).to be > 0 + end + end + + context 'by block' do + it 'creates a timer object' do + timer = instrumentation.time('test_timer') { sleep(0.1) } + + expect(timer).to be_a Honeybadger::Timer + expect(timer.payloads[0][:latest]).to be > 0 + end + end + end + + describe '#gauge' do + context 'by keyword arg' do + it 'creates a gauge object' do + gauge = instrumentation.gauge('test_gauge', value: 1) + instrumentation.gauge('test_gauge', value: 10) + + expect(gauge).to be_a Honeybadger::Gauge + expect(gauge.payloads[0][:latest]).to eq(10) + expect(gauge.payloads[0][:min]).to eq(1) + expect(gauge.payloads[0][:max]).to eq(10) + expect(gauge.payloads[0][:avg]).to eq(5.5) + end + end + + context 'by lambda' do + it 'creates a gauge object' do + gauge = instrumentation.gauge('test_gauge', ->{ 1 }) + instrumentation.gauge('test_gauge', ->{ 10 }) + + expect(gauge).to be_a Honeybadger::Gauge + expect(gauge.payloads[0][:latest]).to eq(10) + expect(gauge.payloads[0][:min]).to eq(1) + expect(gauge.payloads[0][:max]).to eq(10) + expect(gauge.payloads[0][:avg]).to eq(5.5) + end + end + + context 'by block' do + it 'creates a gauge object' do + gauge = instrumentation.gauge('test_gauge') { 1 } + instrumentation.gauge('test_gauge') { 10 } + + expect(gauge).to be_a Honeybadger::Gauge + expect(gauge.payloads[0][:latest]).to eq(10) + expect(gauge.payloads[0][:min]).to eq(1) + expect(gauge.payloads[0][:max]).to eq(10) + expect(gauge.payloads[0][:avg]).to eq(5.5) + end + end + end + + describe '#increment_counter' do + context 'default increment' do + it 'creates a counter object' do + counter = instrumentation.increment_counter('test_counter') + + expect(counter).to be_a Honeybadger::Counter + expect(counter.payloads[0][:counter]).to eq(1) + end + end + + context 'by keyword arg' do + it 'creates a counter object' do + counter = instrumentation.increment_counter('test_counter', by: 1) + + expect(counter).to be_a Honeybadger::Counter + expect(counter.payloads[0][:counter]).to eq(1) + end + end + + context 'by lambda' do + it 'creates a counter object' do + counter = instrumentation.increment_counter('test_counter', ->{ 1 }) + + expect(counter).to be_a Honeybadger::Counter + expect(counter.payloads[0][:counter]).to eq(1) + end + end + + context 'by block' do + it 'creates a counter object' do + counter = instrumentation.increment_counter('test_counter') { 1 } + + expect(counter).to be_a Honeybadger::Counter + expect(counter.payloads[0][:counter]).to eq(1) + end + end + end + + describe '#decrement_counter' do + context 'default decrement' do + it 'creates a counter object' do + counter = instrumentation.decrement_counter('test_counter') + + expect(counter).to be_a Honeybadger::Counter + expect(counter.payloads[0][:counter]).to eq(-1) + end + end + + context 'by keyword arg' do + it 'creates a counter object' do + counter = instrumentation.decrement_counter('test_counter', by: 1) + + expect(counter).to be_a Honeybadger::Counter + expect(counter.payloads[0][:counter]).to eq(-1) + end + end + + context 'by lambda' do + it 'creates a counter object' do + counter = instrumentation.decrement_counter('test_counter', ->{ 1 }) + + expect(counter).to be_a Honeybadger::Counter + expect(counter.payloads[0][:counter]).to eq(-1) + end + end + + context 'by block' do + it 'creates a counter object' do + counter = instrumentation.decrement_counter('test_counter') { 1 } + + expect(counter).to be_a Honeybadger::Counter + expect(counter.payloads[0][:counter]).to eq(-1) + end + end + end + + describe '#histogram' do + context 'by keyword argument' do + it 'creates a histogram object' do + histogram = instrumentation.histogram('test_histogram', duration: 0.0001) + + expect(histogram).to be_a Honeybadger::Histogram + expect(histogram.payloads[0][:bins].map { |b| b[1] }).to include 1 + end + end + + context 'by lambda' do + it 'creates a histogram object' do + histogram = instrumentation.histogram('test_histogram', ->{ sleep(0.0001) }) + + expect(histogram).to be_a Honeybadger::Histogram + expect(histogram.payloads[0][:bins].map { |b| b[1] }).to include 1 + end + end + + context 'by block' do + it 'creates a histogram object' do + histogram = instrumentation.histogram('test_histogram') { sleep(0.0001) } + + expect(histogram).to be_a Honeybadger::Histogram + expect(histogram.payloads[0][:bins].map { |b| b[1] }).to include 1 + end + end + end +end diff --git a/spec/unit/honeybadger/metric_spec.rb b/spec/unit/honeybadger/metric_spec.rb new file mode 100644 index 00000000..c85be92f --- /dev/null +++ b/spec/unit/honeybadger/metric_spec.rb @@ -0,0 +1,47 @@ +# encoding: utf-8 + +describe Honeybadger::Metric do + let(:registry) { Honeybadger::Registry.new } + + describe ".register" do + let(:name) { "capacity" } + let(:attributes) { { foo: "bar" } } + + subject { described_class.register(registry, name, attributes) } + + context "returns a new instance" do + it { should be_a(Honeybadger::Metric) } + end + + context "returns the same instance if called twice" do + let(:instance) { described_class.register(registry, name, attributes) } + + subject { described_class.register(registry, name, attributes) } + + it { should eq(instance) } + end + end + + describe ".signature" do + subject { described_class.signature(metric_type, name, attributes) } + + context "with metric_type, name, and attributes" do + let(:metric_type) { "gauge" } + let(:name) { "capacity" } + let(:attributes) { { foo: "bar" } } + + it { should eq Digest::SHA1.hexdigest("gauge-capacity-foo-bar").to_sym } + end + end + + describe "#signature" do + subject { described_class.new(name, attributes).signature } + + context "with name, and attributes" do + let(:name) { "capacity" } + let(:attributes) { { foo: "bar" } } + + it { should eq Digest::SHA1.hexdigest("metric-capacity-foo-bar").to_sym } + end + end +end diff --git a/spec/unit/honeybadger/metrics_worker_spec.rb b/spec/unit/honeybadger/metrics_worker_spec.rb new file mode 100644 index 00000000..0d2f749c --- /dev/null +++ b/spec/unit/honeybadger/metrics_worker_spec.rb @@ -0,0 +1,208 @@ +require 'timecop' +require 'thread' + +require 'honeybadger/metrics_worker' +require 'honeybadger/config' + +describe Honeybadger::MetricsWorker do + let!(:instance) { described_class.new(config) } + let(:config) { Honeybadger::Config.new(logger: NULL_LOGGER, debug: true, :'insights.enabled' => true) } + let(:obj) { double('CollectionExecution', tick: 1) } + + subject { instance } + + after do + Thread.list.each do |thread| + next unless thread.kind_of?(Honeybadger::MetricsWorker::Thread) + Thread.kill(thread) + end + end + + describe "work depends on tick" do + let(:obj) { double('CollectionExecution', tick: tick) } + + before do + allow(instance).to receive(:sleep) + end + + def flush + instance.push(obj) + instance.flush + end + + context "when tick is not 0" do + let(:tick) { 1 } + + it "does not call" do + expect(obj).not_to receive(:call) + expect(obj).not_to receive(:reset) + flush + end + end + + context "when tick is 0" do + let(:tick) { 0 } + + it "does call" do + expect(obj).to receive(:call).at_least(:once) + expect(obj).to receive(:reset).at_least(:once) + flush + end + end + end + + context "when an exception happens in the worker loop" do + before do + allow(instance.send(:queue)).to receive(:pop).and_raise('fail') + end + + it "does not raise when shutting down" do + instance.push(obj) + + expect { instance.shutdown }.not_to raise_error + end + + it "exits the loop" do + instance.push(obj) + instance.flush + + sleep(0.1) + expect(instance.send(:thread)).not_to be_alive + end + + it "logs the error" do + allow(config.logger).to receive(:error) + expect(config.logger).to receive(:error).with(/error/i) + + instance.push(obj) + instance.flush + end + end + + context "when an exception happens during processing" do + let(:obj) { double('CollectionExecution', tick: 0, call: nil, reset: nil) } + + before do + allow(instance).to receive(:sleep) + allow(obj).to receive(:call).and_raise('fail') + end + + def flush + instance.push(obj) + instance.flush + end + + it "does not raise when shutting down" do + flush + expect { instance.shutdown }.not_to raise_error + end + + it "does not exit the loop" do + flush + expect(instance.send(:thread)).to be_alive + end + + it "logs the error" do + allow(config.logger).to receive(:error) + expect(config.logger).to receive(:error).with(/error/i) + flush + end + end + + describe "#initialize" do + describe "#queue" do + subject { instance.send(:queue) } + + it { should be_a Queue } + end + end + + describe "#push" do + it "flushes payload" do + expect(instance.push(obj)).not_to eq false + instance.flush + end + + context "when not started" do + before do + allow(instance).to receive(:start).and_return false + end + + it "rejects push" do + expect(instance.send(:queue)).not_to receive(:push) + expect(instance.push(obj)).to eq false + end + end + end + + describe "#work" do + it "enqueues after work" do + expect(instance.send(:queue)).to receive(:push).with(obj) + instance.send(:work, obj) + end + end + + describe "#start" do + it "starts the thread" do + expect { subject.start }.to change(subject, :thread).to(kind_of(Thread)) + end + + it "changes the pid to the current pid" do + allow(Process).to receive(:pid).and_return(:expected) + expect { subject.start }.to change(subject, :pid).to(:expected) + end + + context "when shutdown" do + before do + subject.shutdown + end + + it "doesn't start" do + expect { subject.start }.not_to change(subject, :thread) + end + end + + context "when suspended" do + before do + subject.send(:suspend, 300) + end + + context "and restart is in the future" do + it "doesn't start" do + expect { subject.start }.not_to change(subject, :thread) + end + end + + context "and restart is in the past" do + it "starts the thread" do + Timecop.travel(Time.now + 301) do + expect { subject.start }.to change(subject, :thread).to(kind_of(Thread)) + end + end + end + end + end + + describe "#shutdown" do + before { subject.start } + + it "blocks until queue is processed" do + subject.push(obj) + subject.shutdown + end + + it "stops the thread" do + subject.shutdown + + sleep(0.1) + expect(subject.send(:thread)).not_to be_alive + end + end + + describe "#flush" do + it "blocks until queue is flushed" do + subject.push(obj) + subject.flush + end + end +end diff --git a/spec/unit/honeybadger/plugins/karafka_spec.rb b/spec/unit/honeybadger/plugins/karafka_spec.rb index d8634658..296a5087 100644 --- a/spec/unit/honeybadger/plugins/karafka_spec.rb +++ b/spec/unit/honeybadger/plugins/karafka_spec.rb @@ -21,17 +21,34 @@ def self.monitor end end end + let(:monitor) { double('monitor') } let(:event) { double('event') } before do Object.const_set(:Karafka, shim) + allow(::Karafka).to receive(:monitor).and_return(monitor) end after { Object.send(:remove_const, :Karafka) } it "includes integration module into Karafka" do - expect(::Karafka).to receive_message_chain(:monitor, :subscribe).with('error.occurred').and_yield(event) + expect(monitor).to receive(:subscribe).with('error.occurred').and_yield(event) expect(event).to receive(:[]).with(:error).and_return(RuntimeError.new) Honeybadger::Plugin.instances[:karafka].load!(config) end + + context "when Insights instrumentation is enabled" do + let(:config) { Honeybadger::Config.new(logger: NULL_LOGGER, debug: true, :'insights.enabled' => true) } + let(:error_event) { double('error event') } + let(:consumed_event) { double('consumed event', payload: {}) } + + it "includes integration module into Karafka" do + expect(monitor).to receive(:subscribe).with('error.occurred').and_yield(error_event) + expect(error_event).to receive(:[]).with(:error).twice.and_return(RuntimeError.new) + + expect(monitor).to receive(:subscribe).with('consumer.consumed').and_yield(consumed_event) + + Honeybadger::Plugin.instances[:karafka].load!(config) + end + end end end diff --git a/spec/unit/honeybadger/plugins/net_http_spec.rb b/spec/unit/honeybadger/plugins/net_http_spec.rb new file mode 100644 index 00000000..a68002cb --- /dev/null +++ b/spec/unit/honeybadger/plugins/net_http_spec.rb @@ -0,0 +1,35 @@ +require 'honeybadger/plugins/net_http' +require 'honeybadger/config' + +describe "Net::HTTP integration" do + let(:config) { Honeybadger::Config.new(logger: NULL_LOGGER, debug: true, :'insights.enabled' => true) } + + before do + Honeybadger::Plugin.instances[:net_http].reset! + end + + it "includes integration module into Net::HTTP" do + Honeybadger::Plugin.instances[:net_http].load!(config) + expect(Net::HTTP.ancestors).to include(Honeybadger::Plugins::Net::HTTP) + end + + describe "event payload" do + before { stub_request(:get, "http://example.com/") } + + context "report domain only" do + it "contains a domain" do + expect(Honeybadger).to receive(:event).with('request.net_http', hash_including({method: "GET", status: 200, host: "example.com"})) + Net::HTTP.get(URI.parse('http://example.com')) + end + end + + context "report domain and full url" do + before { ::Honeybadger.config[:'net_http.insights.full_url'] = true } + + it "contains a domain and url" do + expect(Honeybadger).to receive(:event).with('request.net_http', hash_including({method: "GET", status: 200, url: "http://example.com", host: "example.com"})) + Net::HTTP.get(URI.parse('http://example.com')) + end + end + end +end diff --git a/spec/unit/honeybadger/plugins/sidekiq_spec.rb b/spec/unit/honeybadger/plugins/sidekiq_spec.rb index 57a5a5db..06bea624 100644 --- a/spec/unit/honeybadger/plugins/sidekiq_spec.rb +++ b/spec/unit/honeybadger/plugins/sidekiq_spec.rb @@ -180,5 +180,56 @@ def self.default_configuration end end end + + describe "collectors" do + let(:config) { Honeybadger::Config.new(logger: NULL_LOGGER, debug: true, :'insights.enabled' => true, :'sidekiq.insights.cluster_collection' => true) } + let(:queues) { [double('queue', name: 'default', latency: 1, size: 10)] } + let(:workers) { [['pid', 'tid', double('work', payload: { queue: 'queue_name' })]] } + let(:processes) { [{"concurrency" => 1, "busy" => 1}] } + + let(:stats_shim) do + Class.new do + def workers_size; end + def processes_size; end + def processed; end + def failed; end + def scheduled_size; end + def enqueued; end + def dead_size; end + def retry_size; end + end + end + + let(:queue_shim) do + Class.new do + end + end + + let(:workers_shim) do + Class.new do + end + end + + let(:process_set_shim) do + Class.new do + end + end + + before do + ::Sidekiq.const_set(:Stats, stats_shim) + ::Sidekiq.const_set(:Queue, queue_shim) + ::Sidekiq.const_set(:Workers, workers_shim) + ::Sidekiq.const_set(:ProcessSet, process_set_shim) + allow(::Sidekiq::Queue).to receive(:all).and_return(queues) + allow(::Sidekiq::Workers).to receive(:new).and_return(workers) + allow(::Sidekiq::ProcessSet).to receive(:new).and_return(processes) + end + + it "can execute collectors" do + Honeybadger::Plugin.instances[:sidekiq].collectors.each do |options, collect_block| + Honeybadger::Plugin::CollectorExecution.new('sidekiq', config, options, &collect_block).call + end + end + end end end diff --git a/spec/unit/honeybadger/timer_spec.rb b/spec/unit/honeybadger/timer_spec.rb new file mode 100644 index 00000000..acd1cd7a --- /dev/null +++ b/spec/unit/honeybadger/timer_spec.rb @@ -0,0 +1,15 @@ +# encoding: utf-8 + +describe Honeybadger::Timer do + describe "#payloads" do + let(:metric) { described_class.new(name, attributes) } + let(:name) { "perform" } + let(:attributes) { { foo: "bar" } } + + subject { metric.payloads } + + before { metric.record(1) } + + it { should eq [{ avg: 1.0, latest: 1, max: 1, min: 1 }] } + end +end diff --git a/spec/unit/honeybadger/worker_spec.rb b/spec/unit/honeybadger/worker_spec.rb index c12e4a19..6e650494 100644 --- a/spec/unit/honeybadger/worker_spec.rb +++ b/spec/unit/honeybadger/worker_spec.rb @@ -240,7 +240,7 @@ def flush it "warns the logger when the queue has additional items" do allow(config.logger).to receive(:warn) - expect(config.logger).to receive(:warn).with(/throttled/i) + expect(config.logger).to receive(:warn).with(/throttled/i).at_least(:once) 30.times do subject.push(obj)