diff --git a/lib/honeybadger/config/defaults.rb b/lib/honeybadger/config/defaults.rb index 505564df..b6691f1d 100644 --- a/lib/honeybadger/config/defaults.rb +++ b/lib/honeybadger/config/defaults.rb @@ -325,6 +325,11 @@ class Boolean; end default: false, type: Boolean }, + "active_agent.insights.enabled": { + description: "Enable automatic data collection for Active Agent.", + default: true, + type: Boolean + }, "active_job.attempt_threshold": { description: "The number of attempts before notifications will be sent.", default: 0, diff --git a/lib/honeybadger/notification_subscriber.rb b/lib/honeybadger/notification_subscriber.rb index 1f315e98..40ee9d1e 100644 --- a/lib/honeybadger/notification_subscriber.rb +++ b/lib/honeybadger/notification_subscriber.rb @@ -19,20 +19,37 @@ def finish(name, id, payload) }.merge(format_payload(payload).compact) record(name, payload) + record_metrics(name, payload) end def record(name, payload) - if Honeybadger.config.load_plugin_insights?(:rails, feature: :active_support_events) - Honeybadger.event(name, payload) - end + Honeybadger.event(name, payload) + end - if Honeybadger.config.load_plugin_insights?(:rails, feature: :metrics) - metric_source "rails" - record_metrics(name, payload) - end + def record_metrics(name, payload) + # noop + end + + def process?(name, payload) + true + end + + def format_payload(payload) + payload + end + end + + class RailsSubscriber < NotificationSubscriber + def record(name, payload) + return unless Honeybadger.config.load_plugin_insights?(:rails, feature: :active_support_events) + Honeybadger.event(name, payload) end def record_metrics(name, payload) + return unless Honeybadger.config.load_plugin_insights?(:rails, feature: :metrics) + + metric_source "rails" + case name when "sql.active_record" gauge("duration.sql.active_record", value: payload[:duration], **payload.slice(:query)) @@ -46,37 +63,29 @@ def record_metrics(name, payload) gauge("duration.#{name}", value: payload[:duration], **payload.slice(:store, :key)) end end - - def process?(event, payload) - true - end - - def format_payload(payload) - payload - end end - class ActionControllerSubscriber < NotificationSubscriber + class ActionControllerSubscriber < RailsSubscriber def format_payload(payload) payload.except(:headers, :request, :response) end end - class ActionControllerCacheSubscriber < NotificationSubscriber + class ActionControllerCacheSubscriber < RailsSubscriber def format_payload(payload) payload[:key] = ::ActiveSupport::Cache.expand_cache_key(payload[:key]) if payload[:key] payload end end - class ActiveSupportCacheSubscriber < NotificationSubscriber + class ActiveSupportCacheSubscriber < RailsSubscriber def format_payload(payload) payload[:key] = ::ActiveSupport::Cache.expand_cache_key(payload[:key]) if payload[:key] payload end end - class ActiveSupportCacheMultiSubscriber < NotificationSubscriber + class ActiveSupportCacheMultiSubscriber < RailsSubscriber def format_payload(payload) payload[:key] = expand_cache_keys_from_payload(payload[:key]) payload[:hits] = expand_cache_keys_from_payload(payload[:hits]) @@ -94,7 +103,7 @@ def expand_cache_keys_from_payload(data) end end - class ActionViewSubscriber < NotificationSubscriber + class ActionViewSubscriber < RailsSubscriber PROJECT_ROOT = defined?(::Rails) ? ::Rails.root.to_s : "" def format_payload(payload) @@ -105,7 +114,7 @@ def format_payload(payload) end end - class ActiveRecordSubscriber < NotificationSubscriber + class ActiveRecordSubscriber < RailsSubscriber def format_payload(payload) { query: Util::SQL.obfuscate(payload[:sql], payload[:connection]&.adapter_name), @@ -114,13 +123,13 @@ def format_payload(payload) } end - def process?(event, payload) + def process?(name, payload) return false if payload[:name] == "SCHEMA" true end end - class ActiveJobSubscriber < NotificationSubscriber + class ActiveJobSubscriber < RailsSubscriber def format_payload(payload) job = payload[:job] jobs = payload[:jobs] @@ -146,7 +155,7 @@ def format_payload(payload) end end - class ActionMailerSubscriber < NotificationSubscriber + class ActionMailerSubscriber < RailsSubscriber def format_payload(payload) # Don't include the mail object in the payload... mail = payload.delete(:mail) @@ -162,7 +171,7 @@ def format_payload(payload) end end - class ActiveStorageSubscriber < NotificationSubscriber + class ActiveStorageSubscriber < RailsSubscriber end class RailsEventSubscriber diff --git a/lib/honeybadger/plugins/active_agent.rb b/lib/honeybadger/plugins/active_agent.rb new file mode 100644 index 00000000..0952a9a8 --- /dev/null +++ b/lib/honeybadger/plugins/active_agent.rb @@ -0,0 +1,28 @@ +require "honeybadger/plugin" +require "honeybadger/notification_subscriber" + +module Honeybadger + module Plugins + module ActiveAgent + + Plugin.register :active_agent do + requirement { defined?(::ActiveAgent) } + requirement { defined?(::ActiveSupport::Notifications) } + + execution do + if config.load_plugin_insights?(:active_agent) + ::ActiveSupport::Notifications.subscribe( + /(prompt|embed|stream_open|stream_close|stream_chunk|tool_call|process)\.active_agent/, + Honeybadger::ActiveAgentSubscriber.new + ) + end + end + end + end + end +end + +module Honeybadger + class ActiveAgentSubscriber < NotificationSubscriber + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 321ade4e..8bfc6d47 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -77,6 +77,14 @@ config.exclude_pattern = "spec/unit/honeybadger/rack/*_spec.rb" end + begin + # ActiveSupport::Notifications is also a soft dependency. + require "active_support/notifications" + rescue LoadError + puts "Excluding specs which depend on ActiveSupport::Notifications." + # noop + end + config.before(:each, framework: :rails) do FileUtils.cp_r(FIXTURES_PATH.join("rails"), current_dir) cd("rails") diff --git a/spec/unit/honeybadger/plugins/active_agent_spec.rb b/spec/unit/honeybadger/plugins/active_agent_spec.rb new file mode 100644 index 00000000..96ce8bf7 --- /dev/null +++ b/spec/unit/honeybadger/plugins/active_agent_spec.rb @@ -0,0 +1,80 @@ +require "honeybadger/plugins/active_agent" +require "honeybadger/config" + +describe "Active Agent Dependency" do + let(:config) { Honeybadger::Config.new(logger: NULL_LOGGER, debug: true) } + + before do + Honeybadger::Plugin.instances[:active_agent].reset! + end + + context "when Active Agent is not installed" do + it "fails quietly" do + expect { Honeybadger::Plugin.instances[:active_agent].load!(config) }.not_to raise_error + end + end + + context "when Active Agent is installed", if: defined?(::ActiveSupport::Notifications) do + let(:active_agent_shim) do + Module.new + end + + before do + Object.const_set(:ActiveAgent, active_agent_shim) + end + + after { Object.send(:remove_const, :ActiveAgent) } + + context "when insights are enabled" do + let(:config) { Honeybadger::Config.new(logger: NULL_LOGGER, debug: true, "insights.enabled": true, "active_agent.insights.enabled": true) } + let(:subscriber) { instance_double(Honeybadger::ActiveAgentSubscriber) } + + before do + allow(Honeybadger::ActiveAgentSubscriber).to receive(:new).and_return(subscriber) + allow(ActiveSupport::Notifications).to receive(:subscribe) + end + + it "subscribes to Active Agent notifications" do + expect(ActiveSupport::Notifications).to receive(:subscribe).with( + match("prompt.active_agent"), + subscriber + ) + Honeybadger::Plugin.instances[:active_agent].load!(config) + end + end + + context "when insights are disabled" do + let(:config) { Honeybadger::Config.new(logger: NULL_LOGGER, debug: true, "insights.enabled": false) } + + before do + allow(ActiveSupport::Notifications).to receive(:subscribe) + end + + it "does not subscribe to notifications" do + expect(ActiveSupport::Notifications).not_to receive(:subscribe) + Honeybadger::Plugin.instances[:active_agent].load!(config) + end + end + + context "when Active Agent insights are disabled" do + let(:config) { Honeybadger::Config.new(logger: NULL_LOGGER, debug: true, "insights.enabled": true, "active_agent.insights.enabled": false) } + + before do + allow(ActiveSupport::Notifications).to receive(:subscribe) + end + + it "does not subscribe to notifications" do + expect(ActiveSupport::Notifications).not_to receive(:subscribe) + Honeybadger::Plugin.instances[:active_agent].load!(config) + end + end + end +end + +describe Honeybadger::ActiveAgentSubscriber do + let(:subscriber) { described_class.new } + + it "is a NotificationSubscriber" do + expect(subscriber).to be_a(Honeybadger::NotificationSubscriber) + end +end diff --git a/spec/unit/honeybadger/plugins/flipper_spec.rb b/spec/unit/honeybadger/plugins/flipper_spec.rb index 3471289f..b0cd0ac9 100644 --- a/spec/unit/honeybadger/plugins/flipper_spec.rb +++ b/spec/unit/honeybadger/plugins/flipper_spec.rb @@ -1,13 +1,6 @@ require "honeybadger/plugins/flipper" require "honeybadger/config" -module ActiveSupport - module Notifications - def self.subscribe(*args) - end - end -end - describe "Flipper Dependency" do let(:config) { Honeybadger::Config.new(logger: NULL_LOGGER, debug: true) } @@ -21,7 +14,7 @@ def self.subscribe(*args) end end - context "when flipper is installed" do + context "when flipper is installed", if: defined?(::ActiveSupport::Notifications) do let(:flipper_shim) do Module.new end