From 2d73563694061e2da99d239f3141740e99f0255b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Jun 2025 22:17:46 +0000 Subject: [PATCH 01/28] Initial plan for issue From 9473701fa2520f47c647287fd13cdba0f4960ba9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Jun 2025 22:20:14 +0000 Subject: [PATCH 02/28] Initial analysis and plan for global lifecycle hooks implementation Co-authored-by: GrantBirki <23362539+GrantBirki@users.noreply.github.com> --- .bundle/config | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.bundle/config b/.bundle/config index 0146a1ce..f9263841 100644 --- a/.bundle/config +++ b/.bundle/config @@ -1,7 +1,8 @@ --- BUNDLE_BIN: "bin" -BUNDLE_PATH: "vendor/gems" +BUNDLE_PATH: "/home/runner/work/hooks/hooks/vendor/bundle" BUNDLE_CACHE_PATH: "vendor/cache" BUNDLE_CACHE_ALL: "true" BUNDLE_SPECIFIC_PLATFORM: "true" BUNDLE_NO_INSTALL: "true" +BUNDLE_DEPLOYMENT: "true" From 5340e15aa4410c1db02b48dd309ce39d078f0491 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Jun 2025 22:33:15 +0000 Subject: [PATCH 03/28] Implement global lifecycle hooks and stats/failbot components Co-authored-by: GrantBirki <23362539+GrantBirki@users.noreply.github.com> --- lib/hooks.rb | 5 + lib/hooks/app/api.rb | 41 ++++ lib/hooks/core/failbot.rb | 50 +++++ lib/hooks/core/global_components.rb | 27 +++ lib/hooks/core/plugin_loader.rb | 81 +++++++- lib/hooks/core/stats.rb | 54 ++++++ lib/hooks/plugins/auth/base.rb | 25 +++ lib/hooks/plugins/handlers/base.rb | 26 +++ lib/hooks/plugins/lifecycle.rb | 26 +++ .../global_lifecycle_hooks_spec.rb | 180 ++++++++++++++++++ spec/unit/lib/hooks/core/failbot_spec.rb | 98 ++++++++++ .../lib/hooks/core/global_components_spec.rb | 54 ++++++ .../core/plugin_loader_lifecycle_spec.rb | 162 ++++++++++++++++ spec/unit/lib/hooks/core/stats_spec.rb | 91 +++++++++ spec/unit/lib/hooks/handlers/base_spec.rb | 90 ++++++++- spec/unit/lib/hooks/plugins/auth/base_spec.rb | 98 +++++++++- spec/unit/lib/hooks/plugins/lifecycle_spec.rb | 77 ++++++++ 17 files changed, 1181 insertions(+), 4 deletions(-) create mode 100644 lib/hooks/core/failbot.rb create mode 100644 lib/hooks/core/global_components.rb create mode 100644 lib/hooks/core/stats.rb create mode 100644 spec/integration/global_lifecycle_hooks_spec.rb create mode 100644 spec/unit/lib/hooks/core/failbot_spec.rb create mode 100644 spec/unit/lib/hooks/core/global_components_spec.rb create mode 100644 spec/unit/lib/hooks/core/plugin_loader_lifecycle_spec.rb create mode 100644 spec/unit/lib/hooks/core/stats_spec.rb diff --git a/lib/hooks.rb b/lib/hooks.rb index 2105ae8a..d1ab8cea 100644 --- a/lib/hooks.rb +++ b/lib/hooks.rb @@ -3,6 +3,11 @@ require_relative "hooks/version" require_relative "hooks/core/builder" +# Load all core components +Dir[File.join(__dir__, "hooks/core/**/*.rb")].sort.each do |file| + require file +end + # Load all plugins (auth plugins, handler plugins, lifecycle hooks, etc.) Dir[File.join(__dir__, "hooks/plugins/**/*.rb")].sort.each do |file| require file diff --git a/lib/hooks/app/api.rb b/lib/hooks/app/api.rb index a11a1fc9..3a1df07a 100644 --- a/lib/hooks/app/api.rb +++ b/lib/hooks/app/api.rb @@ -9,6 +9,7 @@ require_relative "../plugins/handlers/default" require_relative "../core/logger_factory" require_relative "../core/log" +require_relative "../core/plugin_loader" # Import all core endpoint classes dynamically Dir[File.join(__dir__, "endpoints/**/*.rb")].sort.each { |file| require file } @@ -63,6 +64,34 @@ def self.create(config:, endpoints:, log:) # ex: Hooks::Log.info("message") will include request_id, path, handler, etc Core::LogContext.with(request_context) do begin + # Build Rack environment for lifecycle hooks + rack_env = { + "REQUEST_METHOD" => request.request_method, + "PATH_INFO" => request.path_info, + "QUERY_STRING" => request.query_string, + "HTTP_VERSION" => request.env["HTTP_VERSION"], + "REQUEST_URI" => request.url, + "SERVER_NAME" => request.env["SERVER_NAME"], + "SERVER_PORT" => request.env["SERVER_PORT"], + "CONTENT_TYPE" => request.content_type, + "CONTENT_LENGTH" => request.content_length, + "REMOTE_ADDR" => request.env["REMOTE_ADDR"], + "hooks.request_id" => request_id, + "hooks.handler" => handler_class_name, + "hooks.endpoint_config" => endpoint_config + } + + # Add HTTP headers to environment + headers.each do |key, value| + env_key = "HTTP_#{key.upcase.tr('-', '_')}" + rack_env[env_key] = value + end + + # Call lifecycle hooks: on_request + Core::PluginLoader.lifecycle_plugins.each do |plugin| + plugin.on_request(rack_env) + end + enforce_request_limits(config) request.body.rewind raw_body = request.body.read @@ -81,12 +110,24 @@ def self.create(config:, endpoints:, log:) config: endpoint_config ) + # Call lifecycle hooks: on_response + Core::PluginLoader.lifecycle_plugins.each do |plugin| + plugin.on_response(rack_env, response) + end + log.info "request processed successfully by handler: #{handler_class_name}" log.debug "request duration: #{Time.now - start_time}s" status 200 content_type "application/json" response.to_json rescue => e + # Call lifecycle hooks: on_error + if defined?(rack_env) + Core::PluginLoader.lifecycle_plugins.each do |plugin| + plugin.on_error(e, rack_env) + end + end + log.error "request failed: #{e.message}" error_response = { error: e.message, diff --git a/lib/hooks/core/failbot.rb b/lib/hooks/core/failbot.rb new file mode 100644 index 00000000..4f1c4447 --- /dev/null +++ b/lib/hooks/core/failbot.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Hooks + module Core + # Global failbot component for error reporting + # + # This is a stub implementation that does nothing by default. + # Users can replace this with their own implementation for services + # like Sentry, Rollbar, etc. + class Failbot + # Report an error or exception + # + # @param error_or_message [Exception, String] Exception object or error message + # @param context [Hash] Optional context information + # @return [void] + def report(error_or_message, context = {}) + # Override in subclass for actual error reporting + end + + # Report a critical error + # + # @param error_or_message [Exception, String] Exception object or error message + # @param context [Hash] Optional context information + # @return [void] + def critical(error_or_message, context = {}) + # Override in subclass for actual error reporting + end + + # Report a warning + # + # @param message [String] Warning message + # @param context [Hash] Optional context information + # @return [void] + def warning(message, context = {}) + # Override in subclass for actual warning reporting + end + + # Capture an exception during block execution + # + # @param context [Hash] Optional context information + # @return [Object] Return value of the block + def capture(context = {}) + yield + rescue => e + report(e, context) + raise + end + end + end +end diff --git a/lib/hooks/core/global_components.rb b/lib/hooks/core/global_components.rb new file mode 100644 index 00000000..244fadf6 --- /dev/null +++ b/lib/hooks/core/global_components.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative "stats" +require_relative "failbot" + +module Hooks + module Core + # Global registry for shared components accessible throughout the application + class GlobalComponents + @stats = Stats.new + @failbot = Failbot.new + + class << self + attr_accessor :stats, :failbot + + end + + # Reset components to default instances (for testing) + # + # @return [void] + def self.reset + @stats = Stats.new + @failbot = Failbot.new + end + end + end +end diff --git a/lib/hooks/core/plugin_loader.rb b/lib/hooks/core/plugin_loader.rb index f55dee4b..adf67d2b 100644 --- a/lib/hooks/core/plugin_loader.rb +++ b/lib/hooks/core/plugin_loader.rb @@ -5,14 +5,15 @@ module Hooks module Core - # Loads and caches all plugins (auth + handlers) at boot time + # Loads and caches all plugins (auth + handlers + lifecycle) at boot time class PluginLoader # Class-level registries for loaded plugins @auth_plugins = {} @handler_plugins = {} + @lifecycle_plugins = [] class << self - attr_reader :auth_plugins, :handler_plugins + attr_reader :auth_plugins, :handler_plugins, :lifecycle_plugins # Load all plugins at boot time # @@ -22,6 +23,7 @@ def load_all_plugins(config) # Clear existing registries @auth_plugins = {} @handler_plugins = {} + @lifecycle_plugins = [] # Load built-in plugins first load_builtin_plugins @@ -29,6 +31,7 @@ def load_all_plugins(config) # Load custom plugins if directories are configured load_custom_auth_plugins(config[:auth_plugin_dir]) if config[:auth_plugin_dir] load_custom_handler_plugins(config[:handler_plugin_dir]) if config[:handler_plugin_dir] + load_custom_lifecycle_plugins(config[:lifecycle_plugin_dir]) if config[:lifecycle_plugin_dir] # Log loaded plugins log_loaded_plugins @@ -71,6 +74,7 @@ def get_handler_plugin(handler_name) def clear_plugins @auth_plugins = {} @handler_plugins = {} + @lifecycle_plugins = [] end private @@ -119,6 +123,22 @@ def load_custom_handler_plugins(handler_plugin_dir) end end + # Load custom lifecycle plugins from directory + # + # @param lifecycle_plugin_dir [String] Directory containing custom lifecycle plugins + # @return [void] + def load_custom_lifecycle_plugins(lifecycle_plugin_dir) + return unless lifecycle_plugin_dir && Dir.exist?(lifecycle_plugin_dir) + + Dir.glob(File.join(lifecycle_plugin_dir, "*.rb")).sort.each do |file_path| + begin + load_custom_lifecycle_plugin(file_path, lifecycle_plugin_dir) + rescue => e + raise StandardError, "Failed to load lifecycle plugin from #{file_path}: #{e.message}" + end + end + end + # Load a single custom auth plugin file # # @param file_path [String] Path to the auth plugin file @@ -189,6 +209,41 @@ def load_custom_handler_plugin(file_path, handler_plugin_dir) @handler_plugins[class_name] = handler_class end + # Load a single custom lifecycle plugin file + # + # @param file_path [String] Path to the lifecycle plugin file + # @param lifecycle_plugin_dir [String] Base directory for lifecycle plugins + # @return [void] + def load_custom_lifecycle_plugin(file_path, lifecycle_plugin_dir) + # Security: Ensure the file path doesn't escape the lifecycle plugin directory + normalized_lifecycle_dir = Pathname.new(File.expand_path(lifecycle_plugin_dir)) + normalized_file_path = Pathname.new(File.expand_path(file_path)) + unless normalized_file_path.descend.any? { |path| path == normalized_lifecycle_dir } + raise SecurityError, "Lifecycle plugin path outside of lifecycle plugin directory: #{file_path}" + end + + # Extract class name from file (e.g., logging_lifecycle.rb -> LoggingLifecycle) + file_name = File.basename(file_path, ".rb") + class_name = file_name.split("_").map(&:capitalize).join("") + + # Security: Validate class name + unless valid_lifecycle_class_name?(class_name) + raise StandardError, "Invalid lifecycle plugin class name: #{class_name}" + end + + # Load the file + require file_path + + # Get the class and validate it + lifecycle_class = Object.const_get(class_name) + unless lifecycle_class < Hooks::Plugins::Lifecycle + raise StandardError, "Lifecycle plugin class must inherit from Hooks::Plugins::Lifecycle: #{class_name}" + end + + # Register the plugin instance + @lifecycle_plugins << lifecycle_class.new + end + # Log summary of loaded plugins # # @return [void] @@ -201,6 +256,7 @@ def log_loaded_plugins log.info "Loaded #{@auth_plugins.size} auth plugins: #{@auth_plugins.keys.join(', ')}" log.info "Loaded #{@handler_plugins.size} handler plugins: #{@handler_plugins.keys.join(', ')}" + log.info "Loaded #{@lifecycle_plugins.size} lifecycle plugins" end # Validate that an auth plugin class name is safe to load @@ -244,6 +300,27 @@ def valid_handler_class_name?(class_name) true end + + # Validate that a lifecycle plugin class name is safe to load + # + # @param class_name [String] The class name to validate + # @return [Boolean] true if the class name is safe, false otherwise + def valid_lifecycle_class_name?(class_name) + # Must be a string + return false unless class_name.is_a?(String) + + # Must not be empty or only whitespace + return false if class_name.strip.empty? + + # Must match a safe pattern: alphanumeric + underscore, starting with uppercase + # Examples: LoggingLifecycle, MetricsLifecycle, CustomLifecycle + return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/) + + # Must not be a system/built-in class name + return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name) + + true + end end end end diff --git a/lib/hooks/core/stats.rb b/lib/hooks/core/stats.rb new file mode 100644 index 00000000..650a0dd1 --- /dev/null +++ b/lib/hooks/core/stats.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Hooks + module Core + # Global stats component for metrics reporting + # + # This is a stub implementation that does nothing by default. + # Users can replace this with their own implementation for services + # like DataDog, New Relic, etc. + class Stats + # Record a metric + # + # @param metric_name [String] Name of the metric + # @param value [Numeric] Value to record + # @param tags [Hash] Optional tags/labels for the metric + # @return [void] + def record(metric_name, value, tags = {}) + # Override in subclass for actual metrics reporting + end + + # Increment a counter + # + # @param metric_name [String] Name of the counter + # @param tags [Hash] Optional tags/labels for the metric + # @return [void] + def increment(metric_name, tags = {}) + # Override in subclass for actual metrics reporting + end + + # Record a timing metric + # + # @param metric_name [String] Name of the timing metric + # @param duration [Numeric] Duration in seconds + # @param tags [Hash] Optional tags/labels for the metric + # @return [void] + def timing(metric_name, duration, tags = {}) + # Override in subclass for actual metrics reporting + end + + # Measure execution time of a block + # + # @param metric_name [String] Name of the timing metric + # @param tags [Hash] Optional tags/labels for the metric + # @return [Object] Return value of the block + def measure(metric_name, tags = {}) + start_time = Time.now + result = yield + duration = Time.now - start_time + timing(metric_name, duration, tags) + result + end + end + end +end diff --git a/lib/hooks/plugins/auth/base.rb b/lib/hooks/plugins/auth/base.rb index 1b451dbe..a97d38db 100644 --- a/lib/hooks/plugins/auth/base.rb +++ b/lib/hooks/plugins/auth/base.rb @@ -2,6 +2,7 @@ require "rack/utils" require_relative "../../core/log" +require_relative "../../core/global_components" module Hooks module Plugins @@ -33,6 +34,30 @@ def self.log Hooks::Log.instance end + # Global stats component accessor + # @return [Hooks::Core::Stats] Stats instance for metrics reporting + # + # Provides access to the global stats component for reporting metrics + # to services like DataDog, New Relic, etc. + # + # @example Recording a metric in an inherited class + # stats.increment("auth.validation", { plugin: "hmac" }) + def self.stats + Hooks::Core::GlobalComponents.stats + end + + # Global failbot component accessor + # @return [Hooks::Core::Failbot] Failbot instance for error reporting + # + # Provides access to the global failbot component for reporting errors + # to services like Sentry, Rollbar, etc. + # + # @example Reporting an error in an inherited class + # failbot.report("Auth validation failed", { plugin: "hmac" }) + def self.failbot + Hooks::Core::GlobalComponents.failbot + end + # Retrieve the secret from the environment variable based on the key set in the configuration # # Note: This method is intended to be used by subclasses diff --git a/lib/hooks/plugins/handlers/base.rb b/lib/hooks/plugins/handlers/base.rb index e0dffc2a..6dac147c 100644 --- a/lib/hooks/plugins/handlers/base.rb +++ b/lib/hooks/plugins/handlers/base.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative "../../core/global_components" + module Hooks module Plugins module Handlers @@ -29,6 +31,30 @@ def call(payload:, headers:, config:) def log Hooks::Log.instance end + + # Global stats component accessor + # @return [Hooks::Core::Stats] Stats instance for metrics reporting + # + # Provides access to the global stats component for reporting metrics + # to services like DataDog, New Relic, etc. + # + # @example Recording a metric in an inherited class + # stats.increment("webhook.processed", { handler: "MyHandler" }) + def stats + Hooks::Core::GlobalComponents.stats + end + + # Global failbot component accessor + # @return [Hooks::Core::Failbot] Failbot instance for error reporting + # + # Provides access to the global failbot component for reporting errors + # to services like Sentry, Rollbar, etc. + # + # @example Reporting an error in an inherited class + # failbot.report("Something went wrong", { handler: "MyHandler" }) + def failbot + Hooks::Core::GlobalComponents.failbot + end end end end diff --git a/lib/hooks/plugins/lifecycle.rb b/lib/hooks/plugins/lifecycle.rb index c9b54f68..d1f57c96 100644 --- a/lib/hooks/plugins/lifecycle.rb +++ b/lib/hooks/plugins/lifecycle.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative "../core/global_components" + module Hooks module Plugins # Base class for global lifecycle plugins @@ -28,6 +30,30 @@ def on_response(env, response) def on_error(exception, env) # Override in subclass for error handling logic end + + # Global stats component accessor + # @return [Hooks::Core::Stats] Stats instance for metrics reporting + # + # Provides access to the global stats component for reporting metrics + # to services like DataDog, New Relic, etc. + # + # @example Recording a metric in an inherited class + # stats.increment("lifecycle.request_processed") + def stats + Hooks::Core::GlobalComponents.stats + end + + # Global failbot component accessor + # @return [Hooks::Core::Failbot] Failbot instance for error reporting + # + # Provides access to the global failbot component for reporting errors + # to services like Sentry, Rollbar, etc. + # + # @example Reporting an error in an inherited class + # failbot.report("Lifecycle hook failed") + def failbot + Hooks::Core::GlobalComponents.failbot + end end end end diff --git a/spec/integration/global_lifecycle_hooks_spec.rb b/spec/integration/global_lifecycle_hooks_spec.rb new file mode 100644 index 00000000..0c036914 --- /dev/null +++ b/spec/integration/global_lifecycle_hooks_spec.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require_relative "../../lib/hooks" +require "rack/test" +require "json" +require "fileutils" +require "tmpdir" +require "yaml" + +RSpec.describe "Global Lifecycle Hooks Integration" do + include Rack::Test::Methods + + def app + @app ||= Hooks.build(config: config_hash) + end + let(:temp_config_dir) { Dir.mktmpdir("config") } + let(:temp_lifecycle_dir) { Dir.mktmpdir("lifecycle_plugins") } + let(:temp_handler_dir) { Dir.mktmpdir("handler_plugins") } + let(:temp_endpoints_dir) { Dir.mktmpdir("endpoints") } + + let(:config_hash) do + { + lifecycle_plugin_dir: temp_lifecycle_dir, + handler_plugin_dir: temp_handler_dir, + endpoints_dir: temp_endpoints_dir, + log_level: "info", + root_path: "/webhooks", + health_path: "/health", + version_path: "/version", + environment: "development" + } + end + + before do + # Create a test lifecycle plugin + lifecycle_plugin_content = <<~RUBY + class TestingLifecycle < Hooks::Plugins::Lifecycle + @@events = [] + + def self.events + @@events + end + + def self.clear_events + @@events = [] + end + + def on_request(env) + @@events << { + type: :request, + path: env["PATH_INFO"], + method: env["REQUEST_METHOD"], + handler: env["hooks.handler"] + } + end + + def on_response(env, response) + @@events << { + type: :response, + path: env["PATH_INFO"], + response: response, + handler: env["hooks.handler"] + } + end + + def on_error(exception, env) + @@events << { + type: :error, + path: env["PATH_INFO"], + error: exception.class.name, + message: exception.message, + handler: env["hooks.handler"] + } + end + end + RUBY + File.write(File.join(temp_lifecycle_dir, "testing_lifecycle.rb"), lifecycle_plugin_content) + + # Create a test handler plugin that uses stats and failbot + handler_plugin_content = <<~RUBY + class IntegrationTestHandler < Hooks::Plugins::Handlers::Base + def call(payload:, headers:, config:) + stats.increment("handler.called", { handler: "IntegrationTestHandler" }) + + if payload&.dig("should_fail") + failbot.report("Intentional test failure", { payload: }) + raise StandardError, "Test failure requested" + end + + { + status: "success", + handler: "IntegrationTestHandler", + timestamp: Time.now.iso8601, + payload_received: !payload.nil? + } + end + end + RUBY + File.write(File.join(temp_handler_dir, "integration_test_handler.rb"), handler_plugin_content) + + # Create an endpoint configuration + endpoint_config_content = <<~YAML + path: /integration-test + handler: IntegrationTestHandler + YAML + File.write(File.join(temp_endpoints_dir, "integration_test.yml"), endpoint_config_content) + end + + after do + FileUtils.rm_rf(temp_config_dir) + FileUtils.rm_rf(temp_lifecycle_dir) + FileUtils.rm_rf(temp_handler_dir) + FileUtils.rm_rf(temp_endpoints_dir) + + # Clean up any test classes + Object.send(:remove_const, :TestingLifecycle) if defined?(TestingLifecycle) + Object.send(:remove_const, :IntegrationTestHandler) if defined?(IntegrationTestHandler) + end + + it "integrates lifecycle hooks with handler execution and global components" do + # Set up custom stats and failbot to capture events + captured_stats = [] + captured_failbot = [] + + custom_stats = Class.new(Hooks::Core::Stats) do + def initialize(collector) + @collector = collector + end + + def increment(metric_name, tags = {}) + @collector << { action: :increment, metric: metric_name, tags: } + end + end + + custom_failbot = Class.new(Hooks::Core::Failbot) do + def initialize(collector) + @collector = collector + end + + def report(error_or_message, context = {}) + @collector << { action: :report, message: error_or_message, context: } + end + end + + original_stats = Hooks::Core::GlobalComponents.stats + original_failbot = Hooks::Core::GlobalComponents.failbot + + begin + Hooks::Core::GlobalComponents.stats = custom_stats.new(captured_stats) + Hooks::Core::GlobalComponents.failbot = custom_failbot.new(captured_failbot) + + # Force reload to ensure our plugin is loaded + load File.join(temp_lifecycle_dir, "testing_lifecycle.rb") + + # Verify the lifecycle plugin was loaded + expect(defined?(TestingLifecycle)).to be_truthy + TestingLifecycle.clear_events + + # Test successful request + post "/webhooks/integration-test", { "test" => "data" }.to_json, { "CONTENT_TYPE" => "application/json" } + + expect(last_response.status).to eq(200) + response_data = JSON.parse(last_response.body) + expect(response_data["status"]).to eq("success") + expect(response_data["handler"]).to eq("IntegrationTestHandler") + + # Check that stats were recorded + expect(captured_stats).to include( + { action: :increment, metric: "handler.called", tags: { handler: "IntegrationTestHandler" } } + ) + + # Check that lifecycle plugins are available + expect(Hooks::Core::PluginLoader.lifecycle_plugins).not_to be_empty + + ensure + Hooks::Core::GlobalComponents.stats = original_stats + Hooks::Core::GlobalComponents.failbot = original_failbot + end + end +end \ No newline at end of file diff --git a/spec/unit/lib/hooks/core/failbot_spec.rb b/spec/unit/lib/hooks/core/failbot_spec.rb new file mode 100644 index 00000000..b21147cd --- /dev/null +++ b/spec/unit/lib/hooks/core/failbot_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +describe Hooks::Core::Failbot do + let(:failbot) { described_class.new } + + describe "#report" do + it "can be called with string message" do + expect { failbot.report("Test error message") }.not_to raise_error + end + + it "can be called with exception" do + exception = StandardError.new("Test exception") + expect { failbot.report(exception) }.not_to raise_error + end + + it "accepts context parameter" do + expect { failbot.report("Error", { handler: "TestHandler" }) }.not_to raise_error + end + + it "can be overridden in subclasses" do + custom_failbot_class = Class.new(described_class) do + def initialize + @reported_errors = [] + end + + def report(error_or_message, context = {}) + @reported_errors << { error: error_or_message, context: } + end + + attr_reader :reported_errors + end + + custom_failbot = custom_failbot_class.new + custom_failbot.report("Test error", { test: true }) + + expect(custom_failbot.reported_errors).to eq([ + { error: "Test error", context: { test: true } } + ]) + end + end + + describe "#critical" do + it "can be called with string message" do + expect { failbot.critical("Critical error") }.not_to raise_error + end + + it "can be called with exception" do + exception = StandardError.new("Critical exception") + expect { failbot.critical(exception) }.not_to raise_error + end + + it "accepts context parameter" do + expect { failbot.critical("Critical", { handler: "TestHandler" }) }.not_to raise_error + end + end + + describe "#warning" do + it "can be called with message" do + expect { failbot.warning("Warning message") }.not_to raise_error + end + + it "accepts context parameter" do + expect { failbot.warning("Warning", { handler: "TestHandler" }) }.not_to raise_error + end + end + + describe "#capture" do + it "returns block result when no exception" do + result = failbot.capture { "success" } + expect(result).to eq("success") + end + + it "reports and re-raises exceptions" do + capturing_failbot_class = Class.new(described_class) do + def initialize + @captured_errors = [] + end + + def report(error_or_message, context = {}) + @captured_errors << { error: error_or_message, context: } + end + + attr_reader :captured_errors + end + + capturing_failbot = capturing_failbot_class.new + test_error = StandardError.new("Test error") + + expect { + capturing_failbot.capture({ test: true }) { raise test_error } + }.to raise_error(test_error) + + expect(capturing_failbot.captured_errors).to eq([ + { error: test_error, context: { test: true } } + ]) + end + end +end diff --git a/spec/unit/lib/hooks/core/global_components_spec.rb b/spec/unit/lib/hooks/core/global_components_spec.rb new file mode 100644 index 00000000..14e4fa3f --- /dev/null +++ b/spec/unit/lib/hooks/core/global_components_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +describe Hooks::Core::GlobalComponents do + describe ".stats" do + it "returns a Stats instance by default" do + expect(described_class.stats).to be_a(Hooks::Core::Stats) + end + + it "can be set to a custom stats instance" do + custom_stats = double("CustomStats") + original_stats = described_class.stats + + described_class.stats = custom_stats + expect(described_class.stats).to eq(custom_stats) + + # Restore original for other tests + described_class.stats = original_stats + end + end + + describe ".failbot" do + it "returns a Failbot instance by default" do + expect(described_class.failbot).to be_a(Hooks::Core::Failbot) + end + + it "can be set to a custom failbot instance" do + custom_failbot = double("CustomFailbot") + original_failbot = described_class.failbot + + described_class.failbot = custom_failbot + expect(described_class.failbot).to eq(custom_failbot) + + # Restore original for other tests + described_class.failbot = original_failbot + end + end + + describe ".reset" do + it "resets both components to default instances" do + # Set custom instances + custom_stats = double("CustomStats") + custom_failbot = double("CustomFailbot") + described_class.stats = custom_stats + described_class.failbot = custom_failbot + + # Reset + described_class.reset + + # Verify they are back to default instances + expect(described_class.stats).to be_a(Hooks::Core::Stats) + expect(described_class.failbot).to be_a(Hooks::Core::Failbot) + end + end +end diff --git a/spec/unit/lib/hooks/core/plugin_loader_lifecycle_spec.rb b/spec/unit/lib/hooks/core/plugin_loader_lifecycle_spec.rb new file mode 100644 index 00000000..8214b6c8 --- /dev/null +++ b/spec/unit/lib/hooks/core/plugin_loader_lifecycle_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +describe Hooks::Core::PluginLoader do + # Reset plugin state between tests + around do |example| + original_auth = described_class.auth_plugins.dup + original_handler = described_class.handler_plugins.dup + original_lifecycle = described_class.lifecycle_plugins.dup + + example.run + + # Restore original state + described_class.instance_variable_set(:@auth_plugins, original_auth) + described_class.instance_variable_set(:@handler_plugins, original_handler) + described_class.instance_variable_set(:@lifecycle_plugins, original_lifecycle) + end + + describe ".lifecycle_plugins" do + it "returns an array" do + expect(described_class.lifecycle_plugins).to be_an(Array) + end + + it "starts empty" do + described_class.clear_plugins + expect(described_class.lifecycle_plugins).to be_empty + end + end + + describe ".load_all_plugins" do + it "loads lifecycle plugins from directory" do + # Create a temporary lifecycle plugin file + temp_dir = Dir.mktmpdir("lifecycle_plugins") + plugin_file = File.join(temp_dir, "test_lifecycle.rb") + + File.write(plugin_file, <<~RUBY) + class TestLifecycle < Hooks::Plugins::Lifecycle + def on_request(env) + # Test implementation + end + end + RUBY + + config = { lifecycle_plugin_dir: temp_dir } + + expect { + described_class.load_all_plugins(config) + }.not_to raise_error + + expect(described_class.lifecycle_plugins).not_to be_empty + expect(described_class.lifecycle_plugins.first).to be_a(TestLifecycle) + + # Cleanup + FileUtils.rm_rf(temp_dir) + end + + it "handles missing lifecycle plugin directory gracefully" do + config = { lifecycle_plugin_dir: "/nonexistent/directory" } + + expect { + described_class.load_all_plugins(config) + }.not_to raise_error + + # Should not affect existing plugins + expect(described_class.auth_plugins).not_to be_empty + expect(described_class.handler_plugins).not_to be_empty + end + + it "handles nil lifecycle plugin directory gracefully" do + config = { lifecycle_plugin_dir: nil } + + expect { + described_class.load_all_plugins(config) + }.not_to raise_error + end + end + + describe ".clear_plugins" do + it "clears lifecycle plugins" do + # Simulate having some lifecycle plugins + described_class.instance_variable_set(:@lifecycle_plugins, [double("Plugin")]) + + described_class.clear_plugins + + expect(described_class.lifecycle_plugins).to be_empty + end + end + + describe ".log_loaded_plugins" do + it "includes lifecycle plugin count in logs" do + # Mock a logger that captures messages + logger_double = double("Logger") + allow(Hooks::Log).to receive(:instance).and_return(logger_double) + allow(logger_double).to receive(:class).and_return(double(name: "TestLogger")) + + expect(logger_double).to receive(:info).with(/Loaded \d+ auth plugins/) + expect(logger_double).to receive(:info).with(/Loaded \d+ handler plugins/) + expect(logger_double).to receive(:info).with(/Loaded \d+ lifecycle plugins/) + + described_class.send(:log_loaded_plugins) + end + end + + describe "lifecycle plugin validation" do + describe ".valid_lifecycle_class_name?" do + it "accepts valid class names" do + expect(described_class.send(:valid_lifecycle_class_name?, "TestLifecycle")).to be true + expect(described_class.send(:valid_lifecycle_class_name?, "LoggingLifecycle")).to be true + expect(described_class.send(:valid_lifecycle_class_name?, "Custom123Lifecycle")).to be true + end + + it "rejects invalid class names" do + expect(described_class.send(:valid_lifecycle_class_name?, "")).to be false + expect(described_class.send(:valid_lifecycle_class_name?, "lowercase")).to be false + expect(described_class.send(:valid_lifecycle_class_name?, "123Invalid")).to be false + expect(described_class.send(:valid_lifecycle_class_name?, nil)).to be false + expect(described_class.send(:valid_lifecycle_class_name?, "Class-WithDash")).to be false + end + + it "rejects dangerous class names" do + # We can't mock the frozen constant, so we'll test with a name we know is in the list + expect(described_class.send(:valid_lifecycle_class_name?, "Object")).to be false + expect(described_class.send(:valid_lifecycle_class_name?, "Class")).to be false + expect(described_class.send(:valid_lifecycle_class_name?, "Module")).to be false + end + end + end + + describe "lifecycle plugin loading" do + it "validates plugin file paths for security" do + temp_dir = Dir.mktmpdir("lifecycle_plugins") + outside_file = File.join(Dir.tmpdir, "evil_plugin.rb") + + File.write(outside_file, "class EvilPlugin; end") + + expect { + described_class.send(:load_custom_lifecycle_plugin, outside_file, temp_dir) + }.to raise_error(SecurityError, /outside of lifecycle plugin directory/) + + # Cleanup + FileUtils.rm_rf(temp_dir) + File.delete(outside_file) if File.exist?(outside_file) + end + + it "validates plugin inheritance" do + temp_dir = Dir.mktmpdir("lifecycle_plugins") + plugin_file = File.join(temp_dir, "invalid_lifecycle.rb") + + File.write(plugin_file, <<~RUBY) + class InvalidLifecycle + # Does not inherit from Hooks::Plugins::Lifecycle + end + RUBY + + expect { + described_class.send(:load_custom_lifecycle_plugin, plugin_file, temp_dir) + }.to raise_error(StandardError, /must inherit from Hooks::Plugins::Lifecycle/) + + # Cleanup + FileUtils.rm_rf(temp_dir) + end + end +end diff --git a/spec/unit/lib/hooks/core/stats_spec.rb b/spec/unit/lib/hooks/core/stats_spec.rb new file mode 100644 index 00000000..9ba76680 --- /dev/null +++ b/spec/unit/lib/hooks/core/stats_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +describe Hooks::Core::Stats do + let(:stats) { described_class.new } + + describe "#record" do + it "can be called without error" do + expect { stats.record("test.metric", 123) }.not_to raise_error + end + + it "accepts tags parameter" do + expect { stats.record("test.metric", 456, { handler: "TestHandler" }) }.not_to raise_error + end + + it "can be overridden in subclasses" do + custom_stats_class = Class.new(described_class) do + def initialize + @recorded_metrics = [] + end + + def record(metric_name, value, tags = {}) + @recorded_metrics << { metric: metric_name, value:, tags: } + end + + attr_reader :recorded_metrics + end + + custom_stats = custom_stats_class.new + custom_stats.record("test.metric", 789, { test: true }) + + expect(custom_stats.recorded_metrics).to eq([ + { metric: "test.metric", value: 789, tags: { test: true } } + ]) + end + end + + describe "#increment" do + it "can be called without error" do + expect { stats.increment("test.counter") }.not_to raise_error + end + + it "accepts tags parameter" do + expect { stats.increment("test.counter", { handler: "TestHandler" }) }.not_to raise_error + end + end + + describe "#timing" do + it "can be called without error" do + expect { stats.timing("test.timer", 1.5) }.not_to raise_error + end + + it "accepts tags parameter" do + expect { stats.timing("test.timer", 2.3, { handler: "TestHandler" }) }.not_to raise_error + end + end + + describe "#measure" do + it "can measure block execution" do + result = stats.measure("test.execution") { "block result" } + expect(result).to eq("block result") + end + + it "accepts tags parameter" do + result = stats.measure("test.execution", { handler: "TestHandler" }) { 42 } + expect(result).to eq(42) + end + + it "measures timing in subclasses" do + timed_stats_class = Class.new(described_class) do + def initialize + @timings = [] + end + + def timing(metric_name, duration, tags = {}) + @timings << { metric: metric_name, duration:, tags: } + end + + attr_reader :timings + end + + timed_stats = timed_stats_class.new + result = timed_stats.measure("test.block") { "measured" } + + expect(result).to eq("measured") + expect(timed_stats.timings.size).to eq(1) + expect(timed_stats.timings[0][:metric]).to eq("test.block") + expect(timed_stats.timings[0][:duration]).to be_a(Numeric) + expect(timed_stats.timings[0][:duration]).to be >= 0 + end + end +end diff --git a/spec/unit/lib/hooks/handlers/base_spec.rb b/spec/unit/lib/hooks/handlers/base_spec.rb index 717f1356..673f5f5a 100644 --- a/spec/unit/lib/hooks/handlers/base_spec.rb +++ b/spec/unit/lib/hooks/handlers/base_spec.rb @@ -145,7 +145,7 @@ def call(payload:, headers:, config:) describe "documentation compliance" do it "has the expected public interface" do - expect(described_class.instance_methods(false)).to include(:call) + expect(described_class.instance_methods(false)).to include(:call, :log, :stats, :failbot) end it "call method accepts the documented parameters" do @@ -155,4 +155,92 @@ def call(payload:, headers:, config:) expect(method.parameters).to include([:keyreq, :config]) end end + + describe "global component access" do + let(:handler) { described_class.new } + + describe "#log" do + it "provides access to global log" do + expect(handler.log).to be(Hooks::Log.instance) + end + end + + describe "#stats" do + it "provides access to global stats" do + expect(handler.stats).to be_a(Hooks::Core::Stats) + expect(handler.stats).to eq(Hooks::Core::GlobalComponents.stats) + end + end + + describe "#failbot" do + it "provides access to global failbot" do + expect(handler.failbot).to be_a(Hooks::Core::Failbot) + expect(handler.failbot).to eq(Hooks::Core::GlobalComponents.failbot) + end + end + + it "allows stats and failbot usage in subclasses" do + test_handler_class = Class.new(described_class) do + def call(payload:, headers:, config:) + stats.increment("handler.called", { handler: "TestHandler" }) + + if payload.nil? + failbot.report("Payload is nil", { handler: "TestHandler" }) + end + + { status: "processed" } + end + end + + # Create custom components for testing + collected_data = [] + + custom_stats = Class.new(Hooks::Core::Stats) do + def initialize(collector) + @collector = collector + end + + def increment(metric_name, tags = {}) + @collector << { type: :stats, action: :increment, metric: metric_name, tags: } + end + end + + custom_failbot = Class.new(Hooks::Core::Failbot) do + def initialize(collector) + @collector = collector + end + + def report(error_or_message, context = {}) + @collector << { type: :failbot, action: :report, message: error_or_message, context: } + end + end + + original_stats = Hooks::Core::GlobalComponents.stats + original_failbot = Hooks::Core::GlobalComponents.failbot + + begin + Hooks::Core::GlobalComponents.stats = custom_stats.new(collected_data) + Hooks::Core::GlobalComponents.failbot = custom_failbot.new(collected_data) + + handler = test_handler_class.new + + # Test with non-nil payload + handler.call(payload: { "test" => "data" }, headers: {}, config: {}) + expect(collected_data).to include( + { type: :stats, action: :increment, metric: "handler.called", tags: { handler: "TestHandler" } } + ) + + # Test with nil payload + collected_data.clear + handler.call(payload: nil, headers: {}, config: {}) + expect(collected_data).to match_array([ + { type: :stats, action: :increment, metric: "handler.called", tags: { handler: "TestHandler" } }, + { type: :failbot, action: :report, message: "Payload is nil", context: { handler: "TestHandler" } } + ]) + ensure + Hooks::Core::GlobalComponents.stats = original_stats + Hooks::Core::GlobalComponents.failbot = original_failbot + end + end + end end diff --git a/spec/unit/lib/hooks/plugins/auth/base_spec.rb b/spec/unit/lib/hooks/plugins/auth/base_spec.rb index 6a607e25..e176f5f6 100644 --- a/spec/unit/lib/hooks/plugins/auth/base_spec.rb +++ b/spec/unit/lib/hooks/plugins/auth/base_spec.rb @@ -206,7 +206,7 @@ def self.valid?(payload:, headers:, config:) describe "documentation compliance" do it "has the expected public interface" do - expect(described_class.methods(false)).to include(:valid?) + expect(described_class.methods(false)).to include(:valid?, :log, :stats, :failbot, :fetch_secret) end it "valid? method accepts the documented parameters" do @@ -216,4 +216,100 @@ def self.valid?(payload:, headers:, config:) expect(method.parameters).to include([:keyreq, :config]) end end + + describe "global component access" do + describe ".log" do + it "provides access to global log" do + expect(described_class.log).to be(Hooks::Log.instance) + end + end + + describe ".stats" do + it "provides access to global stats" do + expect(described_class.stats).to be_a(Hooks::Core::Stats) + expect(described_class.stats).to eq(Hooks::Core::GlobalComponents.stats) + end + end + + describe ".failbot" do + it "provides access to global failbot" do + expect(described_class.failbot).to be_a(Hooks::Core::Failbot) + expect(described_class.failbot).to eq(Hooks::Core::GlobalComponents.failbot) + end + end + + it "allows stats and failbot usage in subclasses" do + test_auth_class = Class.new(described_class) do + def self.valid?(payload:, headers:, config:) + stats.increment("auth.validation", { plugin: "TestAuth" }) + + # Simulate validation failure + if headers["Authorization"].nil? + failbot.report("Missing authorization header", { plugin: "TestAuth" }) + return false + end + + true + end + end + + # Create custom components for testing + collected_data = [] + + custom_stats = Class.new(Hooks::Core::Stats) do + def initialize(collector) + @collector = collector + end + + def increment(metric_name, tags = {}) + @collector << { type: :stats, action: :increment, metric: metric_name, tags: } + end + end + + custom_failbot = Class.new(Hooks::Core::Failbot) do + def initialize(collector) + @collector = collector + end + + def report(error_or_message, context = {}) + @collector << { type: :failbot, action: :report, message: error_or_message, context: } + end + end + + original_stats = Hooks::Core::GlobalComponents.stats + original_failbot = Hooks::Core::GlobalComponents.failbot + + begin + Hooks::Core::GlobalComponents.stats = custom_stats.new(collected_data) + Hooks::Core::GlobalComponents.failbot = custom_failbot.new(collected_data) + + # Test with authorization header (should pass) + result = test_auth_class.valid?( + payload: '{"test": "data"}', + headers: { "Authorization" => "Bearer token" }, + config: {} + ) + expect(result).to be true + expect(collected_data).to include( + { type: :stats, action: :increment, metric: "auth.validation", tags: { plugin: "TestAuth" } } + ) + + # Test without authorization header (should fail and report error) + collected_data.clear + result = test_auth_class.valid?( + payload: '{"test": "data"}', + headers: {}, + config: {} + ) + expect(result).to be false + expect(collected_data).to match_array([ + { type: :stats, action: :increment, metric: "auth.validation", tags: { plugin: "TestAuth" } }, + { type: :failbot, action: :report, message: "Missing authorization header", context: { plugin: "TestAuth" } } + ]) + ensure + Hooks::Core::GlobalComponents.stats = original_stats + Hooks::Core::GlobalComponents.failbot = original_failbot + end + end + end end diff --git a/spec/unit/lib/hooks/plugins/lifecycle_spec.rb b/spec/unit/lib/hooks/plugins/lifecycle_spec.rb index 2bd42872..2175dfb1 100644 --- a/spec/unit/lib/hooks/plugins/lifecycle_spec.rb +++ b/spec/unit/lib/hooks/plugins/lifecycle_spec.rb @@ -244,4 +244,81 @@ def on_error(exception, env) ]) end end + + describe "global component access" do + describe "#stats" do + it "provides access to global stats" do + expect(plugin.stats).to be_a(Hooks::Core::Stats) + expect(plugin.stats).to eq(Hooks::Core::GlobalComponents.stats) + end + end + + describe "#failbot" do + it "provides access to global failbot" do + expect(plugin.failbot).to be_a(Hooks::Core::Failbot) + expect(plugin.failbot).to eq(Hooks::Core::GlobalComponents.failbot) + end + end + + it "allows stats and failbot usage in subclasses" do + metrics_plugin_class = Class.new(described_class) do + def initialize + @recorded_metrics = [] + @reported_errors = [] + end + + def on_request(env) + stats.increment("lifecycle.request", { path: env["PATH_INFO"] }) + end + + def on_error(exception, env) + failbot.report(exception, { path: env["PATH_INFO"] }) + end + + attr_reader :recorded_metrics, :reported_errors + end + + # Create custom stats and failbot for testing + custom_stats = Class.new(Hooks::Core::Stats) do + def initialize(collector) + @collector = collector + end + + def increment(metric_name, tags = {}) + @collector << { type: :increment, metric: metric_name, tags: } + end + end + + custom_failbot = Class.new(Hooks::Core::Failbot) do + def initialize(collector) + @collector = collector + end + + def report(error_or_message, context = {}) + @collector << { type: :report, error: error_or_message, context: } + end + end + + collected_data = [] + original_stats = Hooks::Core::GlobalComponents.stats + original_failbot = Hooks::Core::GlobalComponents.failbot + + begin + Hooks::Core::GlobalComponents.stats = custom_stats.new(collected_data) + Hooks::Core::GlobalComponents.failbot = custom_failbot.new(collected_data) + + plugin = metrics_plugin_class.new + plugin.on_request(env) + plugin.on_error(exception, env) + + expect(collected_data).to match_array([ + { type: :increment, metric: "lifecycle.request", tags: { path: "/webhook" } }, + { type: :report, error: exception, context: { path: "/webhook" } } + ]) + ensure + Hooks::Core::GlobalComponents.stats = original_stats + Hooks::Core::GlobalComponents.failbot = original_failbot + end + end + end end From 75f85d5ac7006ed991d62d0d1bcf0f98bf732287 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Jun 2025 22:34:03 +0000 Subject: [PATCH 04/28] Fix linting issue in integration test Co-authored-by: GrantBirki <23362539+GrantBirki@users.noreply.github.com> --- spec/integration/global_lifecycle_hooks_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/integration/global_lifecycle_hooks_spec.rb b/spec/integration/global_lifecycle_hooks_spec.rb index 0c036914..9760574f 100644 --- a/spec/integration/global_lifecycle_hooks_spec.rb +++ b/spec/integration/global_lifecycle_hooks_spec.rb @@ -177,4 +177,4 @@ def report(error_or_message, context = {}) Hooks::Core::GlobalComponents.failbot = original_failbot end end -end \ No newline at end of file +end From d0c99ed476126c51883137e74c25a28a93d41120 Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 17:36:17 -0700 Subject: [PATCH 05/28] fix bundle --- .bundle/config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.bundle/config b/.bundle/config index f9263841..7095f6e9 100644 --- a/.bundle/config +++ b/.bundle/config @@ -1,6 +1,6 @@ --- BUNDLE_BIN: "bin" -BUNDLE_PATH: "/home/runner/work/hooks/hooks/vendor/bundle" +BUNDLE_PATH: "vendor/gems" BUNDLE_CACHE_PATH: "vendor/cache" BUNDLE_CACHE_ALL: "true" BUNDLE_SPECIFIC_PLATFORM: "true" From 52fdb86daf381ae726fa76ab01e46c36f770f3ae Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 17:36:55 -0700 Subject: [PATCH 06/28] fix message --- script/integration | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/integration b/script/integration index 59fd26da..dc9c697d 100755 --- a/script/integration +++ b/script/integration @@ -4,7 +4,7 @@ set -e # prevent any kind of script failures source script/env "$@" -echo -e "${PURPLE}[#]${OFF} ${BLUE}Running acceptance tests${OFF}" +echo -e "${PURPLE}[#]${OFF} ${BLUE}Running integration tests${OFF}" bundle exec bin/rspec spec/integration && rspec_exit=$? || rspec_exit=$? echo "" echo "---------------------------------------" From 1c42a81e7b4ab1fb380f29bf7cbc6d9f0751a5b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Jun 2025 00:50:37 +0000 Subject: [PATCH 07/28] Fix acceptance test log output and integration test failure Co-authored-by: GrantBirki <23362539+GrantBirki@users.noreply.github.com> --- .bundle/config | 2 +- lib/hooks/core/config_validator.rb | 1 + script/acceptance | 10 +++++----- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.bundle/config b/.bundle/config index 7095f6e9..f9263841 100644 --- a/.bundle/config +++ b/.bundle/config @@ -1,6 +1,6 @@ --- BUNDLE_BIN: "bin" -BUNDLE_PATH: "vendor/gems" +BUNDLE_PATH: "/home/runner/work/hooks/hooks/vendor/bundle" BUNDLE_CACHE_PATH: "vendor/cache" BUNDLE_CACHE_ALL: "true" BUNDLE_SPECIFIC_PLATFORM: "true" diff --git a/lib/hooks/core/config_validator.rb b/lib/hooks/core/config_validator.rb index 5e1204a1..7429a7a5 100644 --- a/lib/hooks/core/config_validator.rb +++ b/lib/hooks/core/config_validator.rb @@ -15,6 +15,7 @@ class ValidationError < StandardError; end optional(:handler_dir).filled(:string) # For backward compatibility optional(:handler_plugin_dir).filled(:string) optional(:auth_plugin_dir).maybe(:string) + optional(:lifecycle_plugin_dir).maybe(:string) optional(:log_level).filled(:string, included_in?: %w[debug info warn error]) optional(:request_limit).filled(:integer, gt?: 0) optional(:request_timeout).filled(:integer, gt?: 0) diff --git a/script/acceptance b/script/acceptance index 650fdfd2..99b0271b 100755 --- a/script/acceptance +++ b/script/acceptance @@ -23,17 +23,17 @@ if ! docker info &> /dev/null; then fi echo -e "${PURPLE}[#]${OFF} ${BLUE}Killing old docker processes${OFF}" -docker compose -f "$COMPOSE_FILE" down --remove-orphans -v -t 1 -docker network prune --force -docker compose -f "$COMPOSE_FILE" up --build -d +docker compose -f "$COMPOSE_FILE" down --remove-orphans -v -t 1 &> /dev/null +docker network prune --force &> /dev/null +docker compose -f "$COMPOSE_FILE" up --build -d &> /dev/null echo -e "${PURPLE}[#]${OFF} ${BLUE}Running acceptance tests${OFF}" bundle exec rspec spec/acceptance/acceptance_tests.rb if [ "$KEEP_UP" == "false" ]; then echo -e "${PURPLE}[#]${OFF} ${BLUE}Stopping docker processes${OFF}" - docker compose -f "$COMPOSE_FILE" down --remove-orphans -v -t 1 - docker network prune --force + docker compose -f "$COMPOSE_FILE" down --remove-orphans -v -t 1 &> /dev/null + docker network prune --force &> /dev/null else echo -e "${PURPLE}[#]${OFF} ${BLUE}Keeping docker processes up${OFF}" fi From 19695712f776fdda79b3942ed2061006287f8427 Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 17:56:05 -0700 Subject: [PATCH 08/28] revert --- script/acceptance | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/script/acceptance b/script/acceptance index 99b0271b..650fdfd2 100755 --- a/script/acceptance +++ b/script/acceptance @@ -23,17 +23,17 @@ if ! docker info &> /dev/null; then fi echo -e "${PURPLE}[#]${OFF} ${BLUE}Killing old docker processes${OFF}" -docker compose -f "$COMPOSE_FILE" down --remove-orphans -v -t 1 &> /dev/null -docker network prune --force &> /dev/null -docker compose -f "$COMPOSE_FILE" up --build -d &> /dev/null +docker compose -f "$COMPOSE_FILE" down --remove-orphans -v -t 1 +docker network prune --force +docker compose -f "$COMPOSE_FILE" up --build -d echo -e "${PURPLE}[#]${OFF} ${BLUE}Running acceptance tests${OFF}" bundle exec rspec spec/acceptance/acceptance_tests.rb if [ "$KEEP_UP" == "false" ]; then echo -e "${PURPLE}[#]${OFF} ${BLUE}Stopping docker processes${OFF}" - docker compose -f "$COMPOSE_FILE" down --remove-orphans -v -t 1 &> /dev/null - docker network prune --force &> /dev/null + docker compose -f "$COMPOSE_FILE" down --remove-orphans -v -t 1 + docker network prune --force else echo -e "${PURPLE}[#]${OFF} ${BLUE}Keeping docker processes up${OFF}" fi From 1b253833edf539939917cce40d8a827ba3193fc0 Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 17:56:53 -0700 Subject: [PATCH 09/28] revert --- .bundle/config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.bundle/config b/.bundle/config index f9263841..7095f6e9 100644 --- a/.bundle/config +++ b/.bundle/config @@ -1,6 +1,6 @@ --- BUNDLE_BIN: "bin" -BUNDLE_PATH: "/home/runner/work/hooks/hooks/vendor/bundle" +BUNDLE_PATH: "vendor/gems" BUNDLE_CACHE_PATH: "vendor/cache" BUNDLE_CACHE_ALL: "true" BUNDLE_SPECIFIC_PLATFORM: "true" From 044472b1723e9d628f6c3ebf7561db4498d75b1b Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 18:00:30 -0700 Subject: [PATCH 10/28] Add logger accessor to Lifecycle and implement RequestMethodLogger example --- lib/hooks/plugins/lifecycle.rb | 12 ++++++++++++ spec/acceptance/config/hooks.yaml | 1 + .../plugins/lifecycle/request_method_logger.rb | 8 ++++++++ 3 files changed, 21 insertions(+) create mode 100644 spec/acceptance/plugins/lifecycle/request_method_logger.rb diff --git a/lib/hooks/plugins/lifecycle.rb b/lib/hooks/plugins/lifecycle.rb index d1f57c96..86778e21 100644 --- a/lib/hooks/plugins/lifecycle.rb +++ b/lib/hooks/plugins/lifecycle.rb @@ -31,6 +31,18 @@ def on_error(exception, env) # Override in subclass for error handling logic end + # Short logger accessor for all subclasses + # @return [Hooks::Log] Logger instance + # + # Provides a convenient way for lifecycle plugins to log messages without needing + # to reference the full Hooks::Log namespace. + # + # @example Logging an error in an inherited class + # log.error("oh no an error occured") + def log + Hooks::Log.instance + end + # Global stats component accessor # @return [Hooks::Core::Stats] Stats instance for metrics reporting # diff --git a/spec/acceptance/config/hooks.yaml b/spec/acceptance/config/hooks.yaml index 0ff9c7f0..6144931c 100644 --- a/spec/acceptance/config/hooks.yaml +++ b/spec/acceptance/config/hooks.yaml @@ -1,6 +1,7 @@ # Sample configuration for Hooks webhook server handler_plugin_dir: ./spec/acceptance/plugins/handlers auth_plugin_dir: ./spec/acceptance/plugins/auth +lifecycle_plugin_dir: ./spec/acceptance/plugins/lifecycle log_level: debug diff --git a/spec/acceptance/plugins/lifecycle/request_method_logger.rb b/spec/acceptance/plugins/lifecycle/request_method_logger.rb new file mode 100644 index 00000000..543f8945 --- /dev/null +++ b/spec/acceptance/plugins/lifecycle/request_method_logger.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# This is mostly just an example lifecycle plugin that logs the request method as a demonstration +class RequestMethodLogger < Hooks::Plugins::Lifecycle + def on_request(env) + log.debug("on_request called with method: #{env['REQUEST_METHOD']}") + end +end From 0ffb0f1edcd7b29247bf3be123bf9f14de721704 Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 18:00:33 -0700 Subject: [PATCH 11/28] Set log level to error to reduce noise in tests --- spec/integration/global_lifecycle_hooks_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/integration/global_lifecycle_hooks_spec.rb b/spec/integration/global_lifecycle_hooks_spec.rb index 9760574f..15f4186d 100644 --- a/spec/integration/global_lifecycle_hooks_spec.rb +++ b/spec/integration/global_lifecycle_hooks_spec.rb @@ -23,7 +23,7 @@ def app lifecycle_plugin_dir: temp_lifecycle_dir, handler_plugin_dir: temp_handler_dir, endpoints_dir: temp_endpoints_dir, - log_level: "info", + log_level: "error", # Reduce noise in tests root_path: "/webhooks", health_path: "/health", version_path: "/version", From d9e9d28b505172b674b1bc84bf035efa0e835681 Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 18:16:59 -0700 Subject: [PATCH 12/28] start the logger as early in the application call stack as possible --- lib/hooks/app/api.rb | 2 -- lib/hooks/core/builder.rb | 2 ++ lib/hooks/core/global_components.rb | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/hooks/app/api.rb b/lib/hooks/app/api.rb index 3a1df07a..acf74f2a 100644 --- a/lib/hooks/app/api.rb +++ b/lib/hooks/app/api.rb @@ -29,8 +29,6 @@ class << self def self.create(config:, endpoints:, log:) @server_start_time = Time.now - Hooks::Log.instance = log - api_class = Class.new(Grape::API) do content_type :json, "application/json" content_type :txt, "text/plain" diff --git a/lib/hooks/core/builder.rb b/lib/hooks/core/builder.rb index e7ccd129..099b497a 100644 --- a/lib/hooks/core/builder.rb +++ b/lib/hooks/core/builder.rb @@ -35,6 +35,8 @@ def build ) end + Hooks::Log.instance = @log + # Hydrate our Retryable instance Retry.setup!(log: @log) diff --git a/lib/hooks/core/global_components.rb b/lib/hooks/core/global_components.rb index 244fadf6..b199b686 100644 --- a/lib/hooks/core/global_components.rb +++ b/lib/hooks/core/global_components.rb @@ -12,7 +12,6 @@ class GlobalComponents class << self attr_accessor :stats, :failbot - end # Reset components to default instances (for testing) From 6fee9c5433abf159d7f99bf4d91c946b83442e8c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Jun 2025 01:33:02 +0000 Subject: [PATCH 13/28] Plan implementation of instrument plugins system Co-authored-by: GrantBirki <23362539+GrantBirki@users.noreply.github.com> --- .bundle/config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.bundle/config b/.bundle/config index 7095f6e9..f9263841 100644 --- a/.bundle/config +++ b/.bundle/config @@ -1,6 +1,6 @@ --- BUNDLE_BIN: "bin" -BUNDLE_PATH: "vendor/gems" +BUNDLE_PATH: "/home/runner/work/hooks/hooks/vendor/bundle" BUNDLE_CACHE_PATH: "vendor/cache" BUNDLE_CACHE_ALL: "true" BUNDLE_SPECIFIC_PLATFORM: "true" From dd84c7ac83a1bcaf564697689e4ccbca5904ea11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Jun 2025 01:44:21 +0000 Subject: [PATCH 14/28] Implement pluggable instrument system and comprehensive documentation Co-authored-by: GrantBirki <23362539+GrantBirki@users.noreply.github.com> --- docs/instrument_plugins.md | 367 ++++++++++++++++++ docs/lifecycle_plugins.md | 255 ++++++++++++ lib/hooks/core/config_validator.rb | 1 + lib/hooks/core/global_components.rb | 53 ++- lib/hooks/core/plugin_loader.rb | 113 +++++- lib/hooks/plugins/instruments/failbot.rb | 43 ++ lib/hooks/plugins/instruments/failbot_base.rb | 66 ++++ lib/hooks/plugins/instruments/stats.rb | 45 +++ lib/hooks/plugins/instruments/stats_base.rb | 70 ++++ .../lib/hooks/core/global_components_spec.rb | 8 +- .../core/plugin_loader_instruments_spec.rb | 187 +++++++++ .../core/plugin_loader_lifecycle_spec.rb | 1 + spec/unit/lib/hooks/handlers/base_spec.rb | 4 +- spec/unit/lib/hooks/plugins/auth/base_spec.rb | 4 +- .../plugins/instruments/failbot_base_spec.rb | 56 +++ .../hooks/plugins/instruments/failbot_spec.rb | 47 +++ .../plugins/instruments/stats_base_spec.rb | 42 ++ .../hooks/plugins/instruments/stats_spec.rb | 37 ++ spec/unit/lib/hooks/plugins/lifecycle_spec.rb | 4 +- spec/unit/spec_helper.rb | 4 +- 20 files changed, 1381 insertions(+), 26 deletions(-) create mode 100644 docs/instrument_plugins.md create mode 100644 docs/lifecycle_plugins.md create mode 100644 lib/hooks/plugins/instruments/failbot.rb create mode 100644 lib/hooks/plugins/instruments/failbot_base.rb create mode 100644 lib/hooks/plugins/instruments/stats.rb create mode 100644 lib/hooks/plugins/instruments/stats_base.rb create mode 100644 spec/unit/lib/hooks/core/plugin_loader_instruments_spec.rb create mode 100644 spec/unit/lib/hooks/plugins/instruments/failbot_base_spec.rb create mode 100644 spec/unit/lib/hooks/plugins/instruments/failbot_spec.rb create mode 100644 spec/unit/lib/hooks/plugins/instruments/stats_base_spec.rb create mode 100644 spec/unit/lib/hooks/plugins/instruments/stats_spec.rb diff --git a/docs/instrument_plugins.md b/docs/instrument_plugins.md new file mode 100644 index 00000000..71f23695 --- /dev/null +++ b/docs/instrument_plugins.md @@ -0,0 +1,367 @@ +# Instrument Plugins + +Instrument plugins provide global components for cross-cutting concerns like metrics collection and error reporting. The hooks framework includes two built-in instrument types: `stats` for metrics and `failbot` for error reporting. + +## Overview + +By default, the framework provides no-op stub implementations that do nothing. This allows you to write code that calls instrument methods without requiring external dependencies. You can replace these stubs with real implementations that integrate with your monitoring and error reporting services. + +The instrument plugins are accessible throughout the entire application: +- In handlers via `stats` and `failbot` methods +- In auth plugins via `stats` and `failbot` class methods +- In lifecycle plugins via `stats` and `failbot` methods + +## Built-in Instruments + +### Stats + +The stats instrument provides methods for metrics collection: + +```ruby +# Increment counters +stats.increment("webhook.processed", { handler: "MyHandler" }) + +# Record values +stats.record("webhook.payload_size", 1024, { event: "push" }) + +# Record timing manually +stats.timing("webhook.duration", 0.5, { handler: "MyHandler" }) + +# Measure execution time automatically +result = stats.measure("database.query", { table: "webhooks" }) do + # Database operation here + perform_database_query +end +``` + +### Failbot + +The failbot instrument provides methods for error reporting: + +```ruby +# Report exceptions +begin + risky_operation +rescue => e + failbot.report(e, { context: "webhook_processing" }) +end + +# Report critical errors +failbot.critical("Database connection lost", { service: "postgres" }) + +# Report warnings +failbot.warning("Slow response time detected", { duration: 2.5 }) + +# Capture exceptions automatically +result = failbot.capture({ operation: "webhook_validation" }) do + validate_webhook_payload(payload) +end +``` + +## Creating Custom Instruments + +To create custom instrument implementations, inherit from the appropriate base class and implement the required methods. + +### Custom Stats Implementation + +```ruby +# custom_stats.rb +class CustomStats < Hooks::Plugins::Instruments::StatsBase + def initialize + # Initialize your metrics client + @client = MyMetricsService.new( + api_key: ENV["METRICS_API_KEY"], + namespace: "webhooks" + ) + end + + def record(metric_name, value, tags = {}) + @client.gauge(metric_name, value, tags: tags) + rescue => e + log.error("Failed to record metric: #{e.message}") + end + + def increment(metric_name, tags = {}) + @client.increment(metric_name, tags: tags) + rescue => e + log.error("Failed to increment metric: #{e.message}") + end + + def timing(metric_name, duration, tags = {}) + # Convert to milliseconds if your service expects that + duration_ms = (duration * 1000).round + @client.timing(metric_name, duration_ms, tags: tags) + rescue => e + log.error("Failed to record timing: #{e.message}") + end + + # Optional: Add custom methods specific to your service + def histogram(metric_name, value, tags = {}) + @client.histogram(metric_name, value, tags: tags) + rescue => e + log.error("Failed to record histogram: #{e.message}") + end +end +``` + +### Custom Failbot Implementation + +```ruby +# custom_failbot.rb +class CustomFailbot < Hooks::Plugins::Instruments::FailbotBase + def initialize + # Initialize your error reporting client + @client = MyErrorService.new( + api_key: ENV["ERROR_REPORTING_API_KEY"], + environment: ENV["RAILS_ENV"] || "production" + ) + end + + def report(error_or_message, context = {}) + if error_or_message.is_a?(Exception) + @client.report_exception(error_or_message, context) + else + @client.report_message(error_or_message, context) + end + rescue => e + log.error("Failed to report error: #{e.message}") + end + + def critical(error_or_message, context = {}) + enhanced_context = context.merge(severity: "critical") + report(error_or_message, enhanced_context) + end + + def warning(message, context = {}) + enhanced_context = context.merge(severity: "warning") + @client.report_message(message, enhanced_context) + rescue => e + log.error("Failed to report warning: #{e.message}") + end + + # Optional: Add custom methods specific to your service + def set_user_context(user_id:, email: nil) + @client.set_user_context(user_id: user_id, email: email) + rescue => e + log.error("Failed to set user context: #{e.message}") + end + + def add_breadcrumb(message, category: "webhook", data: {}) + @client.add_breadcrumb(message, category: category, data: data) + rescue => e + log.error("Failed to add breadcrumb: #{e.message}") + end +end +``` + +## Configuration + +To use custom instrument plugins, specify the `instruments_plugin_dir` in your configuration: + +```yaml +# hooks.yml +instruments_plugin_dir: ./plugins/instruments +handler_plugin_dir: ./plugins/handlers +auth_plugin_dir: ./plugins/auth +lifecycle_plugin_dir: ./plugins/lifecycle +``` + +Place your instrument plugin files in the specified directory: + +``` +plugins/ +└── instruments/ + ├── custom_stats.rb + └── custom_failbot.rb +``` + +## File Naming and Class Detection + +The framework automatically detects which type of instrument you're creating based on inheritance: + +- Classes inheriting from `StatsBase` become the `stats` instrument +- Classes inheriting from `FailbotBase` become the `failbot` instrument + +File naming follows snake_case to PascalCase conversion: +- `custom_stats.rb` → `CustomStats` +- `datadog_stats.rb` → `DatadogStats` +- `sentry_failbot.rb` → `SentryFailbot` + +You can only have one stats plugin and one failbot plugin loaded. If multiple plugins of the same type are found, the last one loaded will be used. + +## Usage in Your Code + +Once configured, your custom instruments are available throughout the application: + +### In Handlers + +```ruby +class MyHandler < Hooks::Plugins::Handlers::Base + def call(payload:, headers:, config:) + # Use your custom stats methods + stats.increment("handler.calls", { handler: "MyHandler" }) + + # Use custom methods if you added them + stats.histogram("payload.size", payload.to_s.length) if stats.respond_to?(:histogram) + + result = stats.measure("handler.processing", { handler: "MyHandler" }) do + process_webhook(payload, headers, config) + end + + # Use your custom failbot methods + failbot.add_breadcrumb("Handler completed successfully") if failbot.respond_to?(:add_breadcrumb) + + result + rescue => e + failbot.report(e, { handler: "MyHandler", event: headers["x-github-event"] }) + raise + end +end +``` + +### In Lifecycle Plugins + +```ruby +class MetricsLifecycle < Hooks::Plugins::Lifecycle + def on_request(env) + # Your custom stats implementation will be used + stats.increment("requests.total", { + path: env["PATH_INFO"], + method: env["REQUEST_METHOD"] + }) + end + + def on_error(exception, env) + # Your custom failbot implementation will be used + failbot.report(exception, { + path: env["PATH_INFO"], + handler: env["hooks.handler"] + }) + end +end +``` + +## Popular Integrations + +### DataDog Stats + +```ruby +class DatadogStats < Hooks::Plugins::Instruments::StatsBase + def initialize + require "datadog/statsd" + @statsd = Datadog::Statsd.new("localhost", 8125, namespace: "webhooks") + end + + def record(metric_name, value, tags = {}) + @statsd.gauge(metric_name, value, tags: format_tags(tags)) + end + + def increment(metric_name, tags = {}) + @statsd.increment(metric_name, tags: format_tags(tags)) + end + + def timing(metric_name, duration, tags = {}) + @statsd.timing(metric_name, duration, tags: format_tags(tags)) + end + + private + + def format_tags(tags) + tags.map { |k, v| "#{k}:#{v}" } + end +end +``` + +### Sentry Failbot + +```ruby +class SentryFailbot < Hooks::Plugins::Instruments::FailbotBase + def initialize + require "sentry-ruby" + Sentry.init do |config| + config.dsn = ENV["SENTRY_DSN"] + config.environment = ENV["RAILS_ENV"] || "production" + end + end + + def report(error_or_message, context = {}) + Sentry.with_scope do |scope| + context.each { |key, value| scope.set_context(key, value) } + + if error_or_message.is_a?(Exception) + Sentry.capture_exception(error_or_message) + else + Sentry.capture_message(error_or_message) + end + end + end + + def critical(error_or_message, context = {}) + Sentry.with_scope do |scope| + scope.set_level(:fatal) + context.each { |key, value| scope.set_context(key, value) } + + if error_or_message.is_a?(Exception) + Sentry.capture_exception(error_or_message) + else + Sentry.capture_message(error_or_message) + end + end + end + + def warning(message, context = {}) + Sentry.with_scope do |scope| + scope.set_level(:warning) + context.each { |key, value| scope.set_context(key, value) } + Sentry.capture_message(message) + end + end +end +``` + +## Testing Your Instruments + +When testing, you may want to use test doubles or capture calls: + +```ruby +# In your test setup +class TestStats < Hooks::Plugins::Instruments::StatsBase + attr_reader :recorded_metrics + + def initialize + @recorded_metrics = [] + end + + def record(metric_name, value, tags = {}) + @recorded_metrics << { type: :record, name: metric_name, value: value, tags: tags } + end + + def increment(metric_name, tags = {}) + @recorded_metrics << { type: :increment, name: metric_name, tags: tags } + end + + def timing(metric_name, duration, tags = {}) + @recorded_metrics << { type: :timing, name: metric_name, duration: duration, tags: tags } + end +end + +# Use in tests +test_stats = TestStats.new +Hooks::Core::GlobalComponents.stats = test_stats + +# Your test code here + +expect(test_stats.recorded_metrics).to include( + { type: :increment, name: "webhook.processed", tags: { handler: "MyHandler" } } +) +``` + +## Best Practices + +1. **Handle errors gracefully**: Instrument failures should not break webhook processing +2. **Use appropriate log levels**: Log instrument failures at error level +3. **Add timeouts**: Network calls to external services should have reasonable timeouts +4. **Validate configuration**: Check for required environment variables in `initialize` +5. **Document custom methods**: If you add methods beyond the base interface, document them +6. **Consider performance**: Instruments are called frequently, so keep operations fast +7. **Use connection pooling**: For high-throughput scenarios, use connection pooling for external services \ No newline at end of file diff --git a/docs/lifecycle_plugins.md b/docs/lifecycle_plugins.md new file mode 100644 index 00000000..837a343f --- /dev/null +++ b/docs/lifecycle_plugins.md @@ -0,0 +1,255 @@ +# Lifecycle Plugins + +Lifecycle plugins allow you to hook into webhook request processing at three key points in the request lifecycle. This enables you to add custom functionality like metrics collection, error reporting, request logging, and more. + +## Overview + +The webhook processing lifecycle provides three hooks: + +- **`on_request`**: Called before handler execution with request environment data +- **`on_response`**: Called after successful handler execution with response data +- **`on_error`**: Called when any error occurs during request processing + +All lifecycle plugins have access to the global `stats` and `failbot` instruments for metrics and error reporting. + +## Creating a Lifecycle Plugin + +All lifecycle plugins must inherit from `Hooks::Plugins::Lifecycle` and can implement any or all of the lifecycle methods: + +```ruby +class MetricsLifecycle < Hooks::Plugins::Lifecycle + def on_request(env) + # Called before handler execution + # env contains Rack environment with request details + stats.increment("webhook.requests", { + path: env["PATH_INFO"], + method: env["REQUEST_METHOD"] + }) + + log.info "Processing webhook request: #{env['REQUEST_METHOD']} #{env['PATH_INFO']}" + end + + def on_response(env, response) + # Called after successful handler execution + # env contains the request environment + # response contains the handler's response data + stats.timing("webhook.response_time", env["hooks.processing_time"] || 0) + + log.info "Webhook processed successfully: #{response.inspect}" + end + + def on_error(exception, env) + # Called when any error occurs during request processing + # exception is the error that occurred + # env contains the request environment + failbot.report(exception, { + path: env["PATH_INFO"], + handler: env["hooks.handler"], + method: env["REQUEST_METHOD"] + }) + + log.error "Webhook processing failed: #{exception.message}" + end +end +``` + +## Available Data + +### Request Environment (`env`) + +The environment hash contains standard Rack environment variables plus webhook-specific data: + +```ruby +{ + "REQUEST_METHOD" => "POST", + "PATH_INFO" => "/webhook/my-endpoint", + "HTTP_X_GITHUB_EVENT" => "push", + "HTTP_X_HUB_SIGNATURE_256" => "sha256=...", + "hooks.handler" => "MyHandler", + "hooks.config" => { ... }, # Endpoint configuration + "hooks.payload" => { ... }, # Parsed webhook payload + "hooks.headers" => { ... }, # Cleaned HTTP headers + "hooks.processing_time" => 0.123 # Available in on_response +} +``` + +### Response Data + +The response parameter in `on_response` contains the data returned by your handler: + +```ruby +{ + status: "success", + message: "Webhook processed", + data: { ... } +} +``` + +## Global Components + +Lifecycle plugins have access to global components for cross-cutting concerns: + +### Logger (`log`) + +```ruby +def on_request(env) + log.debug("Request details: #{env.inspect}") + log.info("Processing #{env['HTTP_X_GITHUB_EVENT']} event") + log.warn("Missing expected header") unless env["HTTP_X_GITHUB_EVENT"] + log.error("Critical validation failure") +end +``` + +### Stats (`stats`) + +```ruby +def on_request(env) + # Increment counters + stats.increment("webhook.requests", { event: env["HTTP_X_GITHUB_EVENT"] }) + + # Record values + stats.record("webhook.payload_size", env["CONTENT_LENGTH"].to_i) + + # Measure execution time + stats.measure("webhook.processing", { handler: env["hooks.handler"] }) do + # Processing happens in the handler + end +end + +def on_response(env, response) + # Record timing from environment + stats.timing("webhook.duration", env["hooks.processing_time"]) +end +``` + +### Failbot (`failbot`) + +```ruby +def on_error(exception, env) + # Report errors with context + failbot.report(exception, { + endpoint: env["PATH_INFO"], + event_type: env["HTTP_X_GITHUB_EVENT"], + handler: env["hooks.handler"] + }) + + # Report critical errors + failbot.critical("Handler crashed", { handler: env["hooks.handler"] }) + + # Report warnings + failbot.warning("Slow webhook processing", { duration: env["hooks.processing_time"] }) +end + +def on_request(env) + # Capture and report exceptions during processing + failbot.capture({ context: "request_validation" }) do + validate_webhook_signature(env) + end +end +``` + +## Configuration + +To use custom lifecycle plugins, specify the `lifecycle_plugin_dir` in your configuration: + +```yaml +# hooks.yml +lifecycle_plugin_dir: ./plugins/lifecycle +handler_plugin_dir: ./plugins/handlers +auth_plugin_dir: ./plugins/auth +``` + +Place your lifecycle plugin files in the specified directory: + +``` +plugins/ +└── lifecycle/ + ├── metrics_lifecycle.rb + ├── audit_lifecycle.rb + └── performance_lifecycle.rb +``` + +## File Naming + +Plugin files should be named using snake_case and the class name should be PascalCase: + +- `metrics_lifecycle.rb` → `MetricsLifecycle` +- `audit_lifecycle.rb` → `AuditLifecycle` +- `performance_lifecycle.rb` → `PerformanceLifecycle` + +## Example: Complete Monitoring Plugin + +```ruby +class MonitoringLifecycle < Hooks::Plugins::Lifecycle + def on_request(env) + # Record request metrics + stats.increment("webhook.requests.total", { + method: env["REQUEST_METHOD"], + path: env["PATH_INFO"], + event: env["HTTP_X_GITHUB_EVENT"] || "unknown" + }) + + # Log request start + log.info("Webhook request started", { + method: env["REQUEST_METHOD"], + path: env["PATH_INFO"], + user_agent: env["HTTP_USER_AGENT"], + content_length: env["CONTENT_LENGTH"] + }) + end + + def on_response(env, response) + # Record successful processing + stats.increment("webhook.requests.success", { + handler: env["hooks.handler"], + event: env["HTTP_X_GITHUB_EVENT"] + }) + + # Record processing time + if env["hooks.processing_time"] + stats.timing("webhook.processing_time", env["hooks.processing_time"], { + handler: env["hooks.handler"] + }) + end + + log.info("Webhook request completed successfully", { + handler: env["hooks.handler"], + response_type: response.class.name, + processing_time: env["hooks.processing_time"] + }) + end + + def on_error(exception, env) + # Record error metrics + stats.increment("webhook.requests.error", { + handler: env["hooks.handler"], + error_type: exception.class.name, + event: env["HTTP_X_GITHUB_EVENT"] + }) + + # Report error with full context + failbot.report(exception, { + handler: env["hooks.handler"], + method: env["REQUEST_METHOD"], + path: env["PATH_INFO"], + event: env["HTTP_X_GITHUB_EVENT"], + user_agent: env["HTTP_USER_AGENT"], + content_length: env["CONTENT_LENGTH"] + }) + + log.error("Webhook request failed", { + error: exception.message, + handler: env["hooks.handler"], + path: env["PATH_INFO"] + }) + end +end +``` + +## Best Practices + +1. **Keep lifecycle methods fast**: Avoid slow operations that could impact webhook processing performance +2. **Handle errors gracefully**: Lifecycle plugins should not cause webhook processing to fail +3. **Use appropriate log levels**: Debug for detailed info, info for normal flow, warn for issues, error for failures +4. **Include relevant context**: Add useful tags and context to metrics and error reports +5. **Test thoroughly**: Lifecycle plugins run for every webhook request, so bugs have high impact \ No newline at end of file diff --git a/lib/hooks/core/config_validator.rb b/lib/hooks/core/config_validator.rb index 7429a7a5..0fb98007 100644 --- a/lib/hooks/core/config_validator.rb +++ b/lib/hooks/core/config_validator.rb @@ -16,6 +16,7 @@ class ValidationError < StandardError; end optional(:handler_plugin_dir).filled(:string) optional(:auth_plugin_dir).maybe(:string) optional(:lifecycle_plugin_dir).maybe(:string) + optional(:instruments_plugin_dir).maybe(:string) optional(:log_level).filled(:string, included_in?: %w[debug info warn error]) optional(:request_limit).filled(:integer, gt?: 0) optional(:request_timeout).filled(:integer, gt?: 0) diff --git a/lib/hooks/core/global_components.rb b/lib/hooks/core/global_components.rb index b199b686..bc6f715f 100644 --- a/lib/hooks/core/global_components.rb +++ b/lib/hooks/core/global_components.rb @@ -1,25 +1,52 @@ # frozen_string_literal: true -require_relative "stats" -require_relative "failbot" - module Hooks module Core # Global registry for shared components accessible throughout the application class GlobalComponents - @stats = Stats.new - @failbot = Failbot.new + @test_stats = nil + @test_failbot = nil class << self - attr_accessor :stats, :failbot - end + # Get the global stats instance + # @return [Hooks::Plugins::Instruments::StatsBase] Stats instance for metrics reporting + def stats + @test_stats || PluginLoader.get_instrument_plugin(:stats) + end + + # Get the global failbot instance + # @return [Hooks::Plugins::Instruments::FailbotBase] Failbot instance for error reporting + def failbot + @test_failbot || PluginLoader.get_instrument_plugin(:failbot) + end + + # Set a custom stats instance (for testing) + # @param stats_instance [Object] Custom stats instance + def stats=(stats_instance) + @test_stats = stats_instance + end + + # Set a custom failbot instance (for testing) + # @param failbot_instance [Object] Custom failbot instance + def failbot=(failbot_instance) + @test_failbot = failbot_instance + end - # Reset components to default instances (for testing) - # - # @return [void] - def self.reset - @stats = Stats.new - @failbot = Failbot.new + # Reset components to default instances (for testing) + # + # @return [void] + def reset + @test_stats = nil + @test_failbot = nil + # Clear and reload default instruments + PluginLoader.clear_plugins + require_relative "../plugins/instruments/stats" + require_relative "../plugins/instruments/failbot" + PluginLoader.instance_variable_set(:@instrument_plugins, { + stats: Hooks::Plugins::Instruments::Stats.new, + failbot: Hooks::Plugins::Instruments::Failbot.new + }) + end end end end diff --git a/lib/hooks/core/plugin_loader.rb b/lib/hooks/core/plugin_loader.rb index adf67d2b..97869298 100644 --- a/lib/hooks/core/plugin_loader.rb +++ b/lib/hooks/core/plugin_loader.rb @@ -5,15 +5,16 @@ module Hooks module Core - # Loads and caches all plugins (auth + handlers + lifecycle) at boot time + # Loads and caches all plugins (auth + handlers + lifecycle + instruments) at boot time class PluginLoader # Class-level registries for loaded plugins @auth_plugins = {} @handler_plugins = {} @lifecycle_plugins = [] + @instrument_plugins = { stats: nil, failbot: nil } class << self - attr_reader :auth_plugins, :handler_plugins, :lifecycle_plugins + attr_reader :auth_plugins, :handler_plugins, :lifecycle_plugins, :instrument_plugins # Load all plugins at boot time # @@ -24,6 +25,7 @@ def load_all_plugins(config) @auth_plugins = {} @handler_plugins = {} @lifecycle_plugins = [] + @instrument_plugins = { stats: nil, failbot: nil } # Load built-in plugins first load_builtin_plugins @@ -32,6 +34,10 @@ def load_all_plugins(config) load_custom_auth_plugins(config[:auth_plugin_dir]) if config[:auth_plugin_dir] load_custom_handler_plugins(config[:handler_plugin_dir]) if config[:handler_plugin_dir] load_custom_lifecycle_plugins(config[:lifecycle_plugin_dir]) if config[:lifecycle_plugin_dir] + load_custom_instrument_plugins(config[:instruments_plugin_dir]) if config[:instruments_plugin_dir] + + # Load default instruments if no custom ones were loaded + load_default_instruments # Log loaded plugins log_loaded_plugins @@ -68,6 +74,21 @@ def get_handler_plugin(handler_name) plugin_class end + # Get instrument plugin instance by type + # + # @param instrument_type [Symbol] Type of instrument (:stats or :failbot) + # @return [Object] The instrument plugin instance + # @raise [StandardError] if instrument not found + def get_instrument_plugin(instrument_type) + instrument_instance = @instrument_plugins[instrument_type] + + unless instrument_instance + raise StandardError, "Instrument plugin '#{instrument_type}' not found" + end + + instrument_instance + end + # Clear all loaded plugins (for testing purposes) # # @return [void] @@ -75,6 +96,7 @@ def clear_plugins @auth_plugins = {} @handler_plugins = {} @lifecycle_plugins = [] + @instrument_plugins = { stats: nil, failbot: nil } end private @@ -139,6 +161,22 @@ def load_custom_lifecycle_plugins(lifecycle_plugin_dir) end end + # Load custom instrument plugins from directory + # + # @param instruments_plugin_dir [String] Directory containing custom instrument plugins + # @return [void] + def load_custom_instrument_plugins(instruments_plugin_dir) + return unless instruments_plugin_dir && Dir.exist?(instruments_plugin_dir) + + Dir.glob(File.join(instruments_plugin_dir, "*.rb")).sort.each do |file_path| + begin + load_custom_instrument_plugin(file_path, instruments_plugin_dir) + rescue => e + raise StandardError, "Failed to load instrument plugin from #{file_path}: #{e.message}" + end + end + end + # Load a single custom auth plugin file # # @param file_path [String] Path to the auth plugin file @@ -244,6 +282,55 @@ def load_custom_lifecycle_plugin(file_path, lifecycle_plugin_dir) @lifecycle_plugins << lifecycle_class.new end + # Load a single custom instrument plugin file + # + # @param file_path [String] Path to the instrument plugin file + # @param instruments_plugin_dir [String] Base directory for instrument plugins + # @return [void] + def load_custom_instrument_plugin(file_path, instruments_plugin_dir) + # Security: Ensure the file path doesn't escape the instruments plugin directory + normalized_instruments_dir = Pathname.new(File.expand_path(instruments_plugin_dir)) + normalized_file_path = Pathname.new(File.expand_path(file_path)) + unless normalized_file_path.descend.any? { |path| path == normalized_instruments_dir } + raise SecurityError, "Instrument plugin path outside of instruments plugin directory: #{file_path}" + end + + # Extract class name from file (e.g., custom_stats.rb -> CustomStats) + file_name = File.basename(file_path, ".rb") + class_name = file_name.split("_").map(&:capitalize).join("") + + # Security: Validate class name + unless valid_instrument_class_name?(class_name) + raise StandardError, "Invalid instrument plugin class name: #{class_name}" + end + + # Load the file + require file_path + + # Get the class and validate it + instrument_class = Object.const_get(class_name) + + # Determine instrument type based on inheritance + if instrument_class < Hooks::Plugins::Instruments::StatsBase + @instrument_plugins[:stats] = instrument_class.new + elsif instrument_class < Hooks::Plugins::Instruments::FailbotBase + @instrument_plugins[:failbot] = instrument_class.new + else + raise StandardError, "Instrument plugin class must inherit from StatsBase or FailbotBase: #{class_name}" + end + end + + # Load default instrument implementations if no custom ones were loaded + # + # @return [void] + def load_default_instruments + require_relative "../plugins/instruments/stats" + require_relative "../plugins/instruments/failbot" + + @instrument_plugins[:stats] ||= Hooks::Plugins::Instruments::Stats.new + @instrument_plugins[:failbot] ||= Hooks::Plugins::Instruments::Failbot.new + end + # Log summary of loaded plugins # # @return [void] @@ -257,6 +344,7 @@ def log_loaded_plugins log.info "Loaded #{@auth_plugins.size} auth plugins: #{@auth_plugins.keys.join(', ')}" log.info "Loaded #{@handler_plugins.size} handler plugins: #{@handler_plugins.keys.join(', ')}" log.info "Loaded #{@lifecycle_plugins.size} lifecycle plugins" + log.info "Loaded instruments: #{@instrument_plugins.keys.select { |k| @instrument_plugins[k] }.join(', ')}" end # Validate that an auth plugin class name is safe to load @@ -321,6 +409,27 @@ def valid_lifecycle_class_name?(class_name) true end + + # Validate that an instrument plugin class name is safe to load + # + # @param class_name [String] The class name to validate + # @return [Boolean] true if the class name is safe, false otherwise + def valid_instrument_class_name?(class_name) + # Must be a string + return false unless class_name.is_a?(String) + + # Must not be empty or only whitespace + return false if class_name.strip.empty? + + # Must match a safe pattern: alphanumeric + underscore, starting with uppercase + # Examples: CustomStats, CustomFailbot, DatadogStats + return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/) + + # Must not be a system/built-in class name + return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name) + + true + end end end end diff --git a/lib/hooks/plugins/instruments/failbot.rb b/lib/hooks/plugins/instruments/failbot.rb new file mode 100644 index 00000000..d3447d43 --- /dev/null +++ b/lib/hooks/plugins/instruments/failbot.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative "failbot_base" + +module Hooks + module Plugins + module Instruments + # Default failbot instrument implementation + # + # This is a stub implementation that does nothing by default. + # Users can replace this with their own implementation for services + # like Sentry, Rollbar, etc. + class Failbot < FailbotBase + # Report an error or exception + # + # @param error_or_message [Exception, String] Exception object or error message + # @param context [Hash] Optional context information + # @return [void] + def report(error_or_message, context = {}) + # Override in subclass for actual error reporting + end + + # Report a critical error + # + # @param error_or_message [Exception, String] Exception object or error message + # @param context [Hash] Optional context information + # @return [void] + def critical(error_or_message, context = {}) + # Override in subclass for actual error reporting + end + + # Report a warning + # + # @param message [String] Warning message + # @param context [Hash] Optional context information + # @return [void] + def warning(message, context = {}) + # Override in subclass for actual warning reporting + end + end + end + end +end \ No newline at end of file diff --git a/lib/hooks/plugins/instruments/failbot_base.rb b/lib/hooks/plugins/instruments/failbot_base.rb new file mode 100644 index 00000000..2575d51c --- /dev/null +++ b/lib/hooks/plugins/instruments/failbot_base.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Hooks + module Plugins + module Instruments + # Base class for all failbot instrument plugins + # + # All custom failbot implementations must inherit from this class and implement + # the required methods for error reporting. + class FailbotBase + # Short logger accessor for all subclasses + # @return [Hooks::Log] Logger instance + # + # Provides a convenient way for instruments to log messages without needing + # to reference the full Hooks::Log namespace. + # + # @example Logging debug info in an inherited class + # log.debug("Sending error to external service") + def log + Hooks::Log.instance + end + + # Report an error or exception + # + # @param error_or_message [Exception, String] Exception object or error message + # @param context [Hash] Optional context information + # @return [void] + # @raise [NotImplementedError] if not implemented by subclass + def report(error_or_message, context = {}) + raise NotImplementedError, "Failbot instrument must implement #report method" + end + + # Report a critical error + # + # @param error_or_message [Exception, String] Exception object or error message + # @param context [Hash] Optional context information + # @return [void] + # @raise [NotImplementedError] if not implemented by subclass + def critical(error_or_message, context = {}) + raise NotImplementedError, "Failbot instrument must implement #critical method" + end + + # Report a warning + # + # @param message [String] Warning message + # @param context [Hash] Optional context information + # @return [void] + # @raise [NotImplementedError] if not implemented by subclass + def warning(message, context = {}) + raise NotImplementedError, "Failbot instrument must implement #warning method" + end + + # Capture an exception during block execution + # + # @param context [Hash] Optional context information + # @return [Object] Return value of the block + def capture(context = {}) + yield + rescue => e + report(e, context) + raise + end + end + end + end +end \ No newline at end of file diff --git a/lib/hooks/plugins/instruments/stats.rb b/lib/hooks/plugins/instruments/stats.rb new file mode 100644 index 00000000..4a7d2d28 --- /dev/null +++ b/lib/hooks/plugins/instruments/stats.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require_relative "stats_base" + +module Hooks + module Plugins + module Instruments + # Default stats instrument implementation + # + # This is a stub implementation that does nothing by default. + # Users can replace this with their own implementation for services + # like DataDog, New Relic, etc. + class Stats < StatsBase + # Record a metric + # + # @param metric_name [String] Name of the metric + # @param value [Numeric] Value to record + # @param tags [Hash] Optional tags/labels for the metric + # @return [void] + def record(metric_name, value, tags = {}) + # Override in subclass for actual metrics reporting + end + + # Increment a counter + # + # @param metric_name [String] Name of the counter + # @param tags [Hash] Optional tags/labels for the metric + # @return [void] + def increment(metric_name, tags = {}) + # Override in subclass for actual metrics reporting + end + + # Record a timing metric + # + # @param metric_name [String] Name of the timing metric + # @param duration [Numeric] Duration in seconds + # @param tags [Hash] Optional tags/labels for the metric + # @return [void] + def timing(metric_name, duration, tags = {}) + # Override in subclass for actual metrics reporting + end + end + end + end +end \ No newline at end of file diff --git a/lib/hooks/plugins/instruments/stats_base.rb b/lib/hooks/plugins/instruments/stats_base.rb new file mode 100644 index 00000000..9d5271a6 --- /dev/null +++ b/lib/hooks/plugins/instruments/stats_base.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Hooks + module Plugins + module Instruments + # Base class for all stats instrument plugins + # + # All custom stats implementations must inherit from this class and implement + # the required methods for metrics reporting. + class StatsBase + # Short logger accessor for all subclasses + # @return [Hooks::Log] Logger instance + # + # Provides a convenient way for instruments to log messages without needing + # to reference the full Hooks::Log namespace. + # + # @example Logging an error in an inherited class + # log.error("Failed to send metric to external service") + def log + Hooks::Log.instance + end + + # Record a metric + # + # @param metric_name [String] Name of the metric + # @param value [Numeric] Value to record + # @param tags [Hash] Optional tags/labels for the metric + # @return [void] + # @raise [NotImplementedError] if not implemented by subclass + def record(metric_name, value, tags = {}) + raise NotImplementedError, "Stats instrument must implement #record method" + end + + # Increment a counter + # + # @param metric_name [String] Name of the counter + # @param tags [Hash] Optional tags/labels for the metric + # @return [void] + # @raise [NotImplementedError] if not implemented by subclass + def increment(metric_name, tags = {}) + raise NotImplementedError, "Stats instrument must implement #increment method" + end + + # Record a timing metric + # + # @param metric_name [String] Name of the timing metric + # @param duration [Numeric] Duration in seconds + # @param tags [Hash] Optional tags/labels for the metric + # @return [void] + # @raise [NotImplementedError] if not implemented by subclass + def timing(metric_name, duration, tags = {}) + raise NotImplementedError, "Stats instrument must implement #timing method" + end + + # Measure execution time of a block + # + # @param metric_name [String] Name of the timing metric + # @param tags [Hash] Optional tags/labels for the metric + # @return [Object] Return value of the block + def measure(metric_name, tags = {}) + start_time = Time.now + result = yield + duration = Time.now - start_time + timing(metric_name, duration, tags) + result + end + end + end + end +end \ No newline at end of file diff --git a/spec/unit/lib/hooks/core/global_components_spec.rb b/spec/unit/lib/hooks/core/global_components_spec.rb index 14e4fa3f..e30aeea8 100644 --- a/spec/unit/lib/hooks/core/global_components_spec.rb +++ b/spec/unit/lib/hooks/core/global_components_spec.rb @@ -3,7 +3,7 @@ describe Hooks::Core::GlobalComponents do describe ".stats" do it "returns a Stats instance by default" do - expect(described_class.stats).to be_a(Hooks::Core::Stats) + expect(described_class.stats).to be_a(Hooks::Plugins::Instruments::Stats) end it "can be set to a custom stats instance" do @@ -20,7 +20,7 @@ describe ".failbot" do it "returns a Failbot instance by default" do - expect(described_class.failbot).to be_a(Hooks::Core::Failbot) + expect(described_class.failbot).to be_a(Hooks::Plugins::Instruments::Failbot) end it "can be set to a custom failbot instance" do @@ -47,8 +47,8 @@ described_class.reset # Verify they are back to default instances - expect(described_class.stats).to be_a(Hooks::Core::Stats) - expect(described_class.failbot).to be_a(Hooks::Core::Failbot) + expect(described_class.stats).to be_a(Hooks::Plugins::Instruments::Stats) + expect(described_class.failbot).to be_a(Hooks::Plugins::Instruments::Failbot) end end end diff --git a/spec/unit/lib/hooks/core/plugin_loader_instruments_spec.rb b/spec/unit/lib/hooks/core/plugin_loader_instruments_spec.rb new file mode 100644 index 00000000..c3b988ff --- /dev/null +++ b/spec/unit/lib/hooks/core/plugin_loader_instruments_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +describe Hooks::Core::PluginLoader do + describe "instrument plugins" do + let(:test_plugin_dir) { "/tmp/test_instrument_plugins" } + + before do + # Clear plugins before each test + described_class.clear_plugins + + # Create test plugin directory + FileUtils.mkdir_p(test_plugin_dir) + + # Stub built-in plugins + allow(described_class).to receive(:load_builtin_plugins) + end + + after do + # Clean up test directory + FileUtils.rm_rf(test_plugin_dir) if Dir.exist?(test_plugin_dir) + + # Reset to defaults + described_class.clear_plugins + described_class.load_all_plugins({ + auth_plugin_dir: nil, + handler_plugin_dir: nil, + lifecycle_plugin_dir: nil, + instruments_plugin_dir: nil + }) + end + + describe ".load_custom_instrument_plugins" do + it "loads custom stats instrument plugins" do + # Create a custom stats plugin file + custom_stats_content = <<~RUBY + class CustomStats < Hooks::Plugins::Instruments::StatsBase + def record(metric_name, value, tags = {}) + # Custom implementation + end + + def increment(metric_name, tags = {}) + # Custom implementation + end + + def timing(metric_name, duration, tags = {}) + # Custom implementation + end + end + RUBY + + File.write(File.join(test_plugin_dir, "custom_stats.rb"), custom_stats_content) + + expect { described_class.send(:load_custom_instrument_plugins, test_plugin_dir) }.not_to raise_error + + # Verify the stats plugin was loaded + expect(described_class.instrument_plugins[:stats]).to be_a(CustomStats) + end + + it "loads custom failbot instrument plugins" do + # Create a custom failbot plugin file + custom_failbot_content = <<~RUBY + class CustomFailbot < Hooks::Plugins::Instruments::FailbotBase + def report(error_or_message, context = {}) + # Custom implementation + end + + def critical(error_or_message, context = {}) + # Custom implementation + end + + def warning(message, context = {}) + # Custom implementation + end + end + RUBY + + File.write(File.join(test_plugin_dir, "custom_failbot.rb"), custom_failbot_content) + + expect { described_class.send(:load_custom_instrument_plugins, test_plugin_dir) }.not_to raise_error + + # Verify the failbot plugin was loaded + expect(described_class.instrument_plugins[:failbot]).to be_a(CustomFailbot) + end + + it "raises error for invalid inheritance" do + # Create an invalid plugin file that doesn't inherit from base classes + invalid_content = <<~RUBY + class InvalidInstrument + def some_method + # This doesn't inherit from the right base class + end + end + RUBY + + File.write(File.join(test_plugin_dir, "invalid_instrument.rb"), invalid_content) + + expect do + described_class.send(:load_custom_instrument_plugins, test_plugin_dir) + end.to raise_error(StandardError, /must inherit from StatsBase or FailbotBase/) + end + + it "validates class names for security" do + malicious_content = <<~RUBY + class File < Hooks::Plugins::Instruments::StatsBase + def record(metric_name, value, tags = {}) + # Malicious implementation + end + end + RUBY + + File.write(File.join(test_plugin_dir, "file.rb"), malicious_content) + + expect do + described_class.send(:load_custom_instrument_plugins, test_plugin_dir) + end.to raise_error(StandardError, /Invalid instrument plugin class name/) + end + end + + describe ".get_instrument_plugin" do + before do + # Load default instruments + described_class.send(:load_default_instruments) + end + + it "returns the stats instrument" do + stats = described_class.get_instrument_plugin(:stats) + expect(stats).to be_a(Hooks::Plugins::Instruments::Stats) + end + + it "returns the failbot instrument" do + failbot = described_class.get_instrument_plugin(:failbot) + expect(failbot).to be_a(Hooks::Plugins::Instruments::Failbot) + end + + it "raises error for unknown instrument type" do + expect do + described_class.get_instrument_plugin(:unknown) + end.to raise_error(StandardError, "Instrument plugin 'unknown' not found") + end + end + + describe ".load_default_instruments" do + it "loads default stats and failbot instances" do + described_class.send(:load_default_instruments) + + expect(described_class.instrument_plugins[:stats]).to be_a(Hooks::Plugins::Instruments::Stats) + expect(described_class.instrument_plugins[:failbot]).to be_a(Hooks::Plugins::Instruments::Failbot) + end + + it "doesn't override custom instruments if already loaded" do + # Create custom stats + custom_stats_content = <<~RUBY + class MyCustomStats < Hooks::Plugins::Instruments::StatsBase + def record(metric_name, value, tags = {}) + # Custom implementation + end + end + RUBY + + File.write(File.join(test_plugin_dir, "my_custom_stats.rb"), custom_stats_content) + described_class.send(:load_custom_instrument_plugins, test_plugin_dir) + + # Load defaults + described_class.send(:load_default_instruments) + + # Should still have custom stats, but default failbot + expect(described_class.instrument_plugins[:stats]).to be_a(MyCustomStats) + expect(described_class.instrument_plugins[:failbot]).to be_a(Hooks::Plugins::Instruments::Failbot) + end + end + + describe ".valid_instrument_class_name?" do + it "accepts valid class names" do + expect(described_class.send(:valid_instrument_class_name?, "CustomStats")).to be true + expect(described_class.send(:valid_instrument_class_name?, "MyCustomFailbot")).to be true + expect(described_class.send(:valid_instrument_class_name?, "DatadogStats")).to be true + end + + it "rejects invalid class names" do + expect(described_class.send(:valid_instrument_class_name?, "")).to be false + expect(described_class.send(:valid_instrument_class_name?, "lowercaseClass")).to be false + expect(described_class.send(:valid_instrument_class_name?, "Class-With-Dashes")).to be false + expect(described_class.send(:valid_instrument_class_name?, "File")).to be false + end + end + end +end \ No newline at end of file diff --git a/spec/unit/lib/hooks/core/plugin_loader_lifecycle_spec.rb b/spec/unit/lib/hooks/core/plugin_loader_lifecycle_spec.rb index 8214b6c8..2f54d8c8 100644 --- a/spec/unit/lib/hooks/core/plugin_loader_lifecycle_spec.rb +++ b/spec/unit/lib/hooks/core/plugin_loader_lifecycle_spec.rb @@ -95,6 +95,7 @@ def on_request(env) expect(logger_double).to receive(:info).with(/Loaded \d+ auth plugins/) expect(logger_double).to receive(:info).with(/Loaded \d+ handler plugins/) expect(logger_double).to receive(:info).with(/Loaded \d+ lifecycle plugins/) + expect(logger_double).to receive(:info).with(/Loaded instruments:/) described_class.send(:log_loaded_plugins) end diff --git a/spec/unit/lib/hooks/handlers/base_spec.rb b/spec/unit/lib/hooks/handlers/base_spec.rb index 673f5f5a..a433f40d 100644 --- a/spec/unit/lib/hooks/handlers/base_spec.rb +++ b/spec/unit/lib/hooks/handlers/base_spec.rb @@ -167,14 +167,14 @@ def call(payload:, headers:, config:) describe "#stats" do it "provides access to global stats" do - expect(handler.stats).to be_a(Hooks::Core::Stats) + expect(handler.stats).to be_a(Hooks::Plugins::Instruments::Stats) expect(handler.stats).to eq(Hooks::Core::GlobalComponents.stats) end end describe "#failbot" do it "provides access to global failbot" do - expect(handler.failbot).to be_a(Hooks::Core::Failbot) + expect(handler.failbot).to be_a(Hooks::Plugins::Instruments::Failbot) expect(handler.failbot).to eq(Hooks::Core::GlobalComponents.failbot) end end diff --git a/spec/unit/lib/hooks/plugins/auth/base_spec.rb b/spec/unit/lib/hooks/plugins/auth/base_spec.rb index e176f5f6..a9a38ea7 100644 --- a/spec/unit/lib/hooks/plugins/auth/base_spec.rb +++ b/spec/unit/lib/hooks/plugins/auth/base_spec.rb @@ -226,14 +226,14 @@ def self.valid?(payload:, headers:, config:) describe ".stats" do it "provides access to global stats" do - expect(described_class.stats).to be_a(Hooks::Core::Stats) + expect(described_class.stats).to be_a(Hooks::Plugins::Instruments::Stats) expect(described_class.stats).to eq(Hooks::Core::GlobalComponents.stats) end end describe ".failbot" do it "provides access to global failbot" do - expect(described_class.failbot).to be_a(Hooks::Core::Failbot) + expect(described_class.failbot).to be_a(Hooks::Plugins::Instruments::Failbot) expect(described_class.failbot).to eq(Hooks::Core::GlobalComponents.failbot) end end diff --git a/spec/unit/lib/hooks/plugins/instruments/failbot_base_spec.rb b/spec/unit/lib/hooks/plugins/instruments/failbot_base_spec.rb new file mode 100644 index 00000000..349e5234 --- /dev/null +++ b/spec/unit/lib/hooks/plugins/instruments/failbot_base_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +describe Hooks::Plugins::Instruments::FailbotBase do + let(:failbot) { described_class.new } + + describe "#log" do + it "provides access to the global logger" do + allow(Hooks::Log).to receive(:instance).and_return(double("Logger")) + expect(failbot.log).to eq(Hooks::Log.instance) + end + end + + describe "#report" do + it "raises NotImplementedError" do + expect { failbot.report("error", {}) }.to raise_error(NotImplementedError, "Failbot instrument must implement #report method") + end + end + + describe "#critical" do + it "raises NotImplementedError" do + expect { failbot.critical("critical error", {}) }.to raise_error(NotImplementedError, "Failbot instrument must implement #critical method") + end + end + + describe "#warning" do + it "raises NotImplementedError" do + expect { failbot.warning("warning message", {}) }.to raise_error(NotImplementedError, "Failbot instrument must implement #warning method") + end + end + + describe "#capture" do + it "yields block and captures exceptions" do + allow(failbot).to receive(:report) + + result = failbot.capture({ context: "test" }) do + "block_result" + end + + expect(result).to eq("block_result") + expect(failbot).not_to have_received(:report) + end + + it "captures and re-raises exceptions" do + error = StandardError.new("test error") + allow(failbot).to receive(:report) + + expect do + failbot.capture({ context: "test" }) do + raise error + end + end.to raise_error(error) + + expect(failbot).to have_received(:report).with(error, { context: "test" }) + end + end +end \ No newline at end of file diff --git a/spec/unit/lib/hooks/plugins/instruments/failbot_spec.rb b/spec/unit/lib/hooks/plugins/instruments/failbot_spec.rb new file mode 100644 index 00000000..7685a8ce --- /dev/null +++ b/spec/unit/lib/hooks/plugins/instruments/failbot_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +describe Hooks::Plugins::Instruments::Failbot do + let(:failbot) { described_class.new } + + it "inherits from FailbotBase" do + expect(described_class).to be < Hooks::Plugins::Instruments::FailbotBase + end + + describe "#report" do + it "does nothing by default" do + expect { failbot.report("error", {}) }.not_to raise_error + end + end + + describe "#critical" do + it "does nothing by default" do + expect { failbot.critical("critical error", {}) }.not_to raise_error + end + end + + describe "#warning" do + it "does nothing by default" do + expect { failbot.warning("warning message", {}) }.not_to raise_error + end + end + + describe "#capture" do + it "yields block and does nothing on success" do + result = failbot.capture({ context: "test" }) do + "block_result" + end + + expect(result).to eq("block_result") + end + + it "captures but does nothing with exceptions" do + error = StandardError.new("test error") + + expect do + failbot.capture({ context: "test" }) do + raise error + end + end.to raise_error(error) + end + end +end \ No newline at end of file diff --git a/spec/unit/lib/hooks/plugins/instruments/stats_base_spec.rb b/spec/unit/lib/hooks/plugins/instruments/stats_base_spec.rb new file mode 100644 index 00000000..bc615476 --- /dev/null +++ b/spec/unit/lib/hooks/plugins/instruments/stats_base_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +describe Hooks::Plugins::Instruments::StatsBase do + let(:stats) { described_class.new } + + describe "#log" do + it "provides access to the global logger" do + allow(Hooks::Log).to receive(:instance).and_return(double("Logger")) + expect(stats.log).to eq(Hooks::Log.instance) + end + end + + describe "#record" do + it "raises NotImplementedError" do + expect { stats.record("metric", 1.0, {}) }.to raise_error(NotImplementedError, "Stats instrument must implement #record method") + end + end + + describe "#increment" do + it "raises NotImplementedError" do + expect { stats.increment("counter", {}) }.to raise_error(NotImplementedError, "Stats instrument must implement #increment method") + end + end + + describe "#timing" do + it "raises NotImplementedError" do + expect { stats.timing("timer", 0.5, {}) }.to raise_error(NotImplementedError, "Stats instrument must implement #timing method") + end + end + + describe "#measure" do + it "measures execution time and calls timing" do + allow(stats).to receive(:timing) + result = stats.measure("test_metric", { tag: "value" }) do + "block_result" + end + + expect(result).to eq("block_result") + expect(stats).to have_received(:timing).with("test_metric", kind_of(Numeric), { tag: "value" }) + end + end +end \ No newline at end of file diff --git a/spec/unit/lib/hooks/plugins/instruments/stats_spec.rb b/spec/unit/lib/hooks/plugins/instruments/stats_spec.rb new file mode 100644 index 00000000..d304b6a8 --- /dev/null +++ b/spec/unit/lib/hooks/plugins/instruments/stats_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +describe Hooks::Plugins::Instruments::Stats do + let(:stats) { described_class.new } + + it "inherits from StatsBase" do + expect(described_class).to be < Hooks::Plugins::Instruments::StatsBase + end + + describe "#record" do + it "does nothing by default" do + expect { stats.record("metric", 1.0, {}) }.not_to raise_error + end + end + + describe "#increment" do + it "does nothing by default" do + expect { stats.increment("counter", {}) }.not_to raise_error + end + end + + describe "#timing" do + it "does nothing by default" do + expect { stats.timing("timer", 0.5, {}) }.not_to raise_error + end + end + + describe "#measure" do + it "still works for measuring execution time" do + result = stats.measure("test_metric", { tag: "value" }) do + "block_result" + end + + expect(result).to eq("block_result") + end + end +end \ No newline at end of file diff --git a/spec/unit/lib/hooks/plugins/lifecycle_spec.rb b/spec/unit/lib/hooks/plugins/lifecycle_spec.rb index 2175dfb1..07b7379b 100644 --- a/spec/unit/lib/hooks/plugins/lifecycle_spec.rb +++ b/spec/unit/lib/hooks/plugins/lifecycle_spec.rb @@ -248,14 +248,14 @@ def on_error(exception, env) describe "global component access" do describe "#stats" do it "provides access to global stats" do - expect(plugin.stats).to be_a(Hooks::Core::Stats) + expect(plugin.stats).to be_a(Hooks::Plugins::Instruments::Stats) expect(plugin.stats).to eq(Hooks::Core::GlobalComponents.stats) end end describe "#failbot" do it "provides access to global failbot" do - expect(plugin.failbot).to be_a(Hooks::Core::Failbot) + expect(plugin.failbot).to be_a(Hooks::Plugins::Instruments::Failbot) expect(plugin.failbot).to eq(Hooks::Core::GlobalComponents.failbot) end end diff --git a/spec/unit/spec_helper.rb b/spec/unit/spec_helper.rb index 3d7d4e13..3abf307a 100644 --- a/spec/unit/spec_helper.rb +++ b/spec/unit/spec_helper.rb @@ -39,7 +39,9 @@ # Initialize plugins for tests Hooks::Core::PluginLoader.load_all_plugins({ auth_plugin_dir: nil, - handler_plugin_dir: nil + handler_plugin_dir: nil, + lifecycle_plugin_dir: nil, + instruments_plugin_dir: nil }) RSpec.configure do |config| From dc727aac13f95a065f14dd764a3eeb0299ad5b3b Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 19:04:45 -0700 Subject: [PATCH 15/28] fix vendor --- .bundle/config | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.bundle/config b/.bundle/config index f9263841..0146a1ce 100644 --- a/.bundle/config +++ b/.bundle/config @@ -1,8 +1,7 @@ --- BUNDLE_BIN: "bin" -BUNDLE_PATH: "/home/runner/work/hooks/hooks/vendor/bundle" +BUNDLE_PATH: "vendor/gems" BUNDLE_CACHE_PATH: "vendor/cache" BUNDLE_CACHE_ALL: "true" BUNDLE_SPECIFIC_PLATFORM: "true" BUNDLE_NO_INSTALL: "true" -BUNDLE_DEPLOYMENT: "true" From 8e1379a3ba4690239157de64d705df7a1ae7580d Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 19:06:27 -0700 Subject: [PATCH 16/28] lint --- lib/hooks/core/global_components.rb | 70 ++++++++++++++--------------- 1 file changed, 34 insertions(+), 36 deletions(-) diff --git a/lib/hooks/core/global_components.rb b/lib/hooks/core/global_components.rb index bc6f715f..ea3771c0 100644 --- a/lib/hooks/core/global_components.rb +++ b/lib/hooks/core/global_components.rb @@ -7,46 +7,44 @@ class GlobalComponents @test_stats = nil @test_failbot = nil - class << self - # Get the global stats instance - # @return [Hooks::Plugins::Instruments::StatsBase] Stats instance for metrics reporting - def stats - @test_stats || PluginLoader.get_instrument_plugin(:stats) - end + # Get the global stats instance + # @return [Hooks::Plugins::Instruments::StatsBase] Stats instance for metrics reporting + def self.stats + @test_stats || PluginLoader.get_instrument_plugin(:stats) + end - # Get the global failbot instance - # @return [Hooks::Plugins::Instruments::FailbotBase] Failbot instance for error reporting - def failbot - @test_failbot || PluginLoader.get_instrument_plugin(:failbot) - end + # Get the global failbot instance + # @return [Hooks::Plugins::Instruments::FailbotBase] Failbot instance for error reporting + def self.failbot + @test_failbot || PluginLoader.get_instrument_plugin(:failbot) + end - # Set a custom stats instance (for testing) - # @param stats_instance [Object] Custom stats instance - def stats=(stats_instance) - @test_stats = stats_instance - end + # Set a custom stats instance (for testing) + # @param stats_instance [Object] Custom stats instance + def self.stats=(stats_instance) + @test_stats = stats_instance + end - # Set a custom failbot instance (for testing) - # @param failbot_instance [Object] Custom failbot instance - def failbot=(failbot_instance) - @test_failbot = failbot_instance - end + # Set a custom failbot instance (for testing) + # @param failbot_instance [Object] Custom failbot instance + def self.failbot=(failbot_instance) + @test_failbot = failbot_instance + end - # Reset components to default instances (for testing) - # - # @return [void] - def reset - @test_stats = nil - @test_failbot = nil - # Clear and reload default instruments - PluginLoader.clear_plugins - require_relative "../plugins/instruments/stats" - require_relative "../plugins/instruments/failbot" - PluginLoader.instance_variable_set(:@instrument_plugins, { - stats: Hooks::Plugins::Instruments::Stats.new, - failbot: Hooks::Plugins::Instruments::Failbot.new - }) - end + # Reset components to default instances (for testing) + # + # @return [void] + def self.reset + @test_stats = nil + @test_failbot = nil + # Clear and reload default instruments + PluginLoader.clear_plugins + require_relative "../plugins/instruments/stats" + require_relative "../plugins/instruments/failbot" + PluginLoader.instance_variable_set(:@instrument_plugins, { + stats: Hooks::Plugins::Instruments::Stats.new, + failbot: Hooks::Plugins::Instruments::Failbot.new + }) end end end From ba4076f9f3437b710c374e2611c08a39ecac5d2f Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 19:14:37 -0700 Subject: [PATCH 17/28] add a custom stats instrument to the acceptance stack --- spec/acceptance/config/hooks.yaml | 1 + spec/acceptance/plugins/instruments/stats.rb | 11 +++++++++++ .../plugins/lifecycle/request_method_logger.rb | 4 ++++ 3 files changed, 16 insertions(+) create mode 100644 spec/acceptance/plugins/instruments/stats.rb diff --git a/spec/acceptance/config/hooks.yaml b/spec/acceptance/config/hooks.yaml index 6144931c..8da97489 100644 --- a/spec/acceptance/config/hooks.yaml +++ b/spec/acceptance/config/hooks.yaml @@ -2,6 +2,7 @@ handler_plugin_dir: ./spec/acceptance/plugins/handlers auth_plugin_dir: ./spec/acceptance/plugins/auth lifecycle_plugin_dir: ./spec/acceptance/plugins/lifecycle +instruments_plugin_dir: ./spec/acceptance/plugins/instruments log_level: debug diff --git a/spec/acceptance/plugins/instruments/stats.rb b/spec/acceptance/plugins/instruments/stats.rb new file mode 100644 index 00000000..20e39d5f --- /dev/null +++ b/spec/acceptance/plugins/instruments/stats.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Stats < Hooks::Plugins::Instruments::StatsBase + def initialize + # just a demo implementation + end + + def success + log.debug("response success recorded") + end +end diff --git a/spec/acceptance/plugins/lifecycle/request_method_logger.rb b/spec/acceptance/plugins/lifecycle/request_method_logger.rb index 543f8945..5136d8f4 100644 --- a/spec/acceptance/plugins/lifecycle/request_method_logger.rb +++ b/spec/acceptance/plugins/lifecycle/request_method_logger.rb @@ -5,4 +5,8 @@ class RequestMethodLogger < Hooks::Plugins::Lifecycle def on_request(env) log.debug("on_request called with method: #{env['REQUEST_METHOD']}") end + + def on_response(env, response) + stats.success + end end From bd1f3315f8e3009f9f77dd93045ac5a921ed9653 Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 19:15:54 -0700 Subject: [PATCH 18/28] add an on_response hook to the acceptance stack as an example --- spec/acceptance/plugins/lifecycle/request_method_logger.rb | 4 ---- spec/acceptance/plugins/lifecycle/response_success_hook.rb | 7 +++++++ 2 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 spec/acceptance/plugins/lifecycle/response_success_hook.rb diff --git a/spec/acceptance/plugins/lifecycle/request_method_logger.rb b/spec/acceptance/plugins/lifecycle/request_method_logger.rb index 5136d8f4..543f8945 100644 --- a/spec/acceptance/plugins/lifecycle/request_method_logger.rb +++ b/spec/acceptance/plugins/lifecycle/request_method_logger.rb @@ -5,8 +5,4 @@ class RequestMethodLogger < Hooks::Plugins::Lifecycle def on_request(env) log.debug("on_request called with method: #{env['REQUEST_METHOD']}") end - - def on_response(env, response) - stats.success - end end diff --git a/spec/acceptance/plugins/lifecycle/response_success_hook.rb b/spec/acceptance/plugins/lifecycle/response_success_hook.rb new file mode 100644 index 00000000..9b572c79 --- /dev/null +++ b/spec/acceptance/plugins/lifecycle/response_success_hook.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ResponseSuccessHook < Hooks::Plugins::Lifecycle + def on_response(env, response) + stats.success + end +end From 0627714a196a4851a68eba739b8cca4ed061a996 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Jun 2025 02:23:16 +0000 Subject: [PATCH 19/28] fix: ensure app is built before accessing global components in integration test Co-authored-by: GrantBirki <23362539+GrantBirki@users.noreply.github.com> --- lib/hooks/plugins/instruments/failbot.rb | 2 +- lib/hooks/plugins/instruments/failbot_base.rb | 2 +- lib/hooks/plugins/instruments/stats.rb | 2 +- lib/hooks/plugins/instruments/stats_base.rb | 2 +- .../global_lifecycle_hooks_spec.rb | 3 + .../core/plugin_loader_instruments_spec.rb | 168 +++++++++--------- .../plugins/instruments/failbot_base_spec.rb | 2 +- .../hooks/plugins/instruments/failbot_spec.rb | 2 +- .../plugins/instruments/stats_base_spec.rb | 2 +- .../hooks/plugins/instruments/stats_spec.rb | 2 +- 10 files changed, 95 insertions(+), 92 deletions(-) diff --git a/lib/hooks/plugins/instruments/failbot.rb b/lib/hooks/plugins/instruments/failbot.rb index d3447d43..a0a8681b 100644 --- a/lib/hooks/plugins/instruments/failbot.rb +++ b/lib/hooks/plugins/instruments/failbot.rb @@ -40,4 +40,4 @@ def warning(message, context = {}) end end end -end \ No newline at end of file +end diff --git a/lib/hooks/plugins/instruments/failbot_base.rb b/lib/hooks/plugins/instruments/failbot_base.rb index 2575d51c..a6c8fd53 100644 --- a/lib/hooks/plugins/instruments/failbot_base.rb +++ b/lib/hooks/plugins/instruments/failbot_base.rb @@ -63,4 +63,4 @@ def capture(context = {}) end end end -end \ No newline at end of file +end diff --git a/lib/hooks/plugins/instruments/stats.rb b/lib/hooks/plugins/instruments/stats.rb index 4a7d2d28..49107963 100644 --- a/lib/hooks/plugins/instruments/stats.rb +++ b/lib/hooks/plugins/instruments/stats.rb @@ -42,4 +42,4 @@ def timing(metric_name, duration, tags = {}) end end end -end \ No newline at end of file +end diff --git a/lib/hooks/plugins/instruments/stats_base.rb b/lib/hooks/plugins/instruments/stats_base.rb index 9d5271a6..832bbd67 100644 --- a/lib/hooks/plugins/instruments/stats_base.rb +++ b/lib/hooks/plugins/instruments/stats_base.rb @@ -67,4 +67,4 @@ def measure(metric_name, tags = {}) end end end -end \ No newline at end of file +end diff --git a/spec/integration/global_lifecycle_hooks_spec.rb b/spec/integration/global_lifecycle_hooks_spec.rb index 15f4186d..03b3c38f 100644 --- a/spec/integration/global_lifecycle_hooks_spec.rb +++ b/spec/integration/global_lifecycle_hooks_spec.rb @@ -142,6 +142,9 @@ def report(error_or_message, context = {}) end end + # Ensure the app is built and plugins are loaded before accessing global components + app + original_stats = Hooks::Core::GlobalComponents.stats original_failbot = Hooks::Core::GlobalComponents.failbot diff --git a/spec/unit/lib/hooks/core/plugin_loader_instruments_spec.rb b/spec/unit/lib/hooks/core/plugin_loader_instruments_spec.rb index c3b988ff..25b08f83 100644 --- a/spec/unit/lib/hooks/core/plugin_loader_instruments_spec.rb +++ b/spec/unit/lib/hooks/core/plugin_loader_instruments_spec.rb @@ -29,10 +29,10 @@ }) end - describe ".load_custom_instrument_plugins" do - it "loads custom stats instrument plugins" do - # Create a custom stats plugin file - custom_stats_content = <<~RUBY + describe ".load_custom_instrument_plugins" do + it "loads custom stats instrument plugins" do + # Create a custom stats plugin file + custom_stats_content = <<~RUBY class CustomStats < Hooks::Plugins::Instruments::StatsBase def record(metric_name, value, tags = {}) # Custom implementation @@ -48,17 +48,17 @@ def timing(metric_name, duration, tags = {}) end RUBY - File.write(File.join(test_plugin_dir, "custom_stats.rb"), custom_stats_content) + File.write(File.join(test_plugin_dir, "custom_stats.rb"), custom_stats_content) - expect { described_class.send(:load_custom_instrument_plugins, test_plugin_dir) }.not_to raise_error + expect { described_class.send(:load_custom_instrument_plugins, test_plugin_dir) }.not_to raise_error - # Verify the stats plugin was loaded - expect(described_class.instrument_plugins[:stats]).to be_a(CustomStats) - end + # Verify the stats plugin was loaded + expect(described_class.instrument_plugins[:stats]).to be_a(CustomStats) + end - it "loads custom failbot instrument plugins" do - # Create a custom failbot plugin file - custom_failbot_content = <<~RUBY + it "loads custom failbot instrument plugins" do + # Create a custom failbot plugin file + custom_failbot_content = <<~RUBY class CustomFailbot < Hooks::Plugins::Instruments::FailbotBase def report(error_or_message, context = {}) # Custom implementation @@ -74,17 +74,17 @@ def warning(message, context = {}) end RUBY - File.write(File.join(test_plugin_dir, "custom_failbot.rb"), custom_failbot_content) + File.write(File.join(test_plugin_dir, "custom_failbot.rb"), custom_failbot_content) - expect { described_class.send(:load_custom_instrument_plugins, test_plugin_dir) }.not_to raise_error + expect { described_class.send(:load_custom_instrument_plugins, test_plugin_dir) }.not_to raise_error - # Verify the failbot plugin was loaded - expect(described_class.instrument_plugins[:failbot]).to be_a(CustomFailbot) - end + # Verify the failbot plugin was loaded + expect(described_class.instrument_plugins[:failbot]).to be_a(CustomFailbot) + end - it "raises error for invalid inheritance" do - # Create an invalid plugin file that doesn't inherit from base classes - invalid_content = <<~RUBY + it "raises error for invalid inheritance" do + # Create an invalid plugin file that doesn't inherit from base classes + invalid_content = <<~RUBY class InvalidInstrument def some_method # This doesn't inherit from the right base class @@ -92,15 +92,15 @@ def some_method end RUBY - File.write(File.join(test_plugin_dir, "invalid_instrument.rb"), invalid_content) + File.write(File.join(test_plugin_dir, "invalid_instrument.rb"), invalid_content) - expect do - described_class.send(:load_custom_instrument_plugins, test_plugin_dir) - end.to raise_error(StandardError, /must inherit from StatsBase or FailbotBase/) - end + expect do + described_class.send(:load_custom_instrument_plugins, test_plugin_dir) + end.to raise_error(StandardError, /must inherit from StatsBase or FailbotBase/) + end - it "validates class names for security" do - malicious_content = <<~RUBY + it "validates class names for security" do + malicious_content = <<~RUBY class File < Hooks::Plugins::Instruments::StatsBase def record(metric_name, value, tags = {}) # Malicious implementation @@ -108,48 +108,48 @@ def record(metric_name, value, tags = {}) end RUBY - File.write(File.join(test_plugin_dir, "file.rb"), malicious_content) - - expect do - described_class.send(:load_custom_instrument_plugins, test_plugin_dir) - end.to raise_error(StandardError, /Invalid instrument plugin class name/) - end - end - - describe ".get_instrument_plugin" do - before do - # Load default instruments - described_class.send(:load_default_instruments) - end - - it "returns the stats instrument" do - stats = described_class.get_instrument_plugin(:stats) - expect(stats).to be_a(Hooks::Plugins::Instruments::Stats) - end + File.write(File.join(test_plugin_dir, "file.rb"), malicious_content) - it "returns the failbot instrument" do - failbot = described_class.get_instrument_plugin(:failbot) - expect(failbot).to be_a(Hooks::Plugins::Instruments::Failbot) + expect do + described_class.send(:load_custom_instrument_plugins, test_plugin_dir) + end.to raise_error(StandardError, /Invalid instrument plugin class name/) + end end - it "raises error for unknown instrument type" do - expect do - described_class.get_instrument_plugin(:unknown) - end.to raise_error(StandardError, "Instrument plugin 'unknown' not found") + describe ".get_instrument_plugin" do + before do + # Load default instruments + described_class.send(:load_default_instruments) + end + + it "returns the stats instrument" do + stats = described_class.get_instrument_plugin(:stats) + expect(stats).to be_a(Hooks::Plugins::Instruments::Stats) + end + + it "returns the failbot instrument" do + failbot = described_class.get_instrument_plugin(:failbot) + expect(failbot).to be_a(Hooks::Plugins::Instruments::Failbot) + end + + it "raises error for unknown instrument type" do + expect do + described_class.get_instrument_plugin(:unknown) + end.to raise_error(StandardError, "Instrument plugin 'unknown' not found") + end end - end - describe ".load_default_instruments" do - it "loads default stats and failbot instances" do - described_class.send(:load_default_instruments) + describe ".load_default_instruments" do + it "loads default stats and failbot instances" do + described_class.send(:load_default_instruments) - expect(described_class.instrument_plugins[:stats]).to be_a(Hooks::Plugins::Instruments::Stats) - expect(described_class.instrument_plugins[:failbot]).to be_a(Hooks::Plugins::Instruments::Failbot) - end + expect(described_class.instrument_plugins[:stats]).to be_a(Hooks::Plugins::Instruments::Stats) + expect(described_class.instrument_plugins[:failbot]).to be_a(Hooks::Plugins::Instruments::Failbot) + end - it "doesn't override custom instruments if already loaded" do - # Create custom stats - custom_stats_content = <<~RUBY + it "doesn't override custom instruments if already loaded" do + # Create custom stats + custom_stats_content = <<~RUBY class MyCustomStats < Hooks::Plugins::Instruments::StatsBase def record(metric_name, value, tags = {}) # Custom implementation @@ -157,31 +157,31 @@ def record(metric_name, value, tags = {}) end RUBY - File.write(File.join(test_plugin_dir, "my_custom_stats.rb"), custom_stats_content) - described_class.send(:load_custom_instrument_plugins, test_plugin_dir) + File.write(File.join(test_plugin_dir, "my_custom_stats.rb"), custom_stats_content) + described_class.send(:load_custom_instrument_plugins, test_plugin_dir) - # Load defaults - described_class.send(:load_default_instruments) + # Load defaults + described_class.send(:load_default_instruments) - # Should still have custom stats, but default failbot - expect(described_class.instrument_plugins[:stats]).to be_a(MyCustomStats) - expect(described_class.instrument_plugins[:failbot]).to be_a(Hooks::Plugins::Instruments::Failbot) + # Should still have custom stats, but default failbot + expect(described_class.instrument_plugins[:stats]).to be_a(MyCustomStats) + expect(described_class.instrument_plugins[:failbot]).to be_a(Hooks::Plugins::Instruments::Failbot) + end end - end - describe ".valid_instrument_class_name?" do - it "accepts valid class names" do - expect(described_class.send(:valid_instrument_class_name?, "CustomStats")).to be true - expect(described_class.send(:valid_instrument_class_name?, "MyCustomFailbot")).to be true - expect(described_class.send(:valid_instrument_class_name?, "DatadogStats")).to be true + describe ".valid_instrument_class_name?" do + it "accepts valid class names" do + expect(described_class.send(:valid_instrument_class_name?, "CustomStats")).to be true + expect(described_class.send(:valid_instrument_class_name?, "MyCustomFailbot")).to be true + expect(described_class.send(:valid_instrument_class_name?, "DatadogStats")).to be true + end + + it "rejects invalid class names" do + expect(described_class.send(:valid_instrument_class_name?, "")).to be false + expect(described_class.send(:valid_instrument_class_name?, "lowercaseClass")).to be false + expect(described_class.send(:valid_instrument_class_name?, "Class-With-Dashes")).to be false + expect(described_class.send(:valid_instrument_class_name?, "File")).to be false + end end - - it "rejects invalid class names" do - expect(described_class.send(:valid_instrument_class_name?, "")).to be false - expect(described_class.send(:valid_instrument_class_name?, "lowercaseClass")).to be false - expect(described_class.send(:valid_instrument_class_name?, "Class-With-Dashes")).to be false - expect(described_class.send(:valid_instrument_class_name?, "File")).to be false - end - end end -end \ No newline at end of file +end diff --git a/spec/unit/lib/hooks/plugins/instruments/failbot_base_spec.rb b/spec/unit/lib/hooks/plugins/instruments/failbot_base_spec.rb index 349e5234..a3b2475a 100644 --- a/spec/unit/lib/hooks/plugins/instruments/failbot_base_spec.rb +++ b/spec/unit/lib/hooks/plugins/instruments/failbot_base_spec.rb @@ -53,4 +53,4 @@ expect(failbot).to have_received(:report).with(error, { context: "test" }) end end -end \ No newline at end of file +end diff --git a/spec/unit/lib/hooks/plugins/instruments/failbot_spec.rb b/spec/unit/lib/hooks/plugins/instruments/failbot_spec.rb index 7685a8ce..e6bb1302 100644 --- a/spec/unit/lib/hooks/plugins/instruments/failbot_spec.rb +++ b/spec/unit/lib/hooks/plugins/instruments/failbot_spec.rb @@ -44,4 +44,4 @@ end.to raise_error(error) end end -end \ No newline at end of file +end diff --git a/spec/unit/lib/hooks/plugins/instruments/stats_base_spec.rb b/spec/unit/lib/hooks/plugins/instruments/stats_base_spec.rb index bc615476..21586f58 100644 --- a/spec/unit/lib/hooks/plugins/instruments/stats_base_spec.rb +++ b/spec/unit/lib/hooks/plugins/instruments/stats_base_spec.rb @@ -39,4 +39,4 @@ expect(stats).to have_received(:timing).with("test_metric", kind_of(Numeric), { tag: "value" }) end end -end \ No newline at end of file +end diff --git a/spec/unit/lib/hooks/plugins/instruments/stats_spec.rb b/spec/unit/lib/hooks/plugins/instruments/stats_spec.rb index d304b6a8..3d85f3fa 100644 --- a/spec/unit/lib/hooks/plugins/instruments/stats_spec.rb +++ b/spec/unit/lib/hooks/plugins/instruments/stats_spec.rb @@ -34,4 +34,4 @@ expect(result).to eq("block_result") end end -end \ No newline at end of file +end From 7f8b54f4be54be161d14eaf431e20f946f68012c Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 20:56:04 -0700 Subject: [PATCH 20/28] docs --- README.md | 8 ++++ docs/instrument_plugins.md | 77 ++++++++++---------------------------- docs/lifecycle_plugins.md | 20 +++++----- 3 files changed, 38 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index a7cd9e7d..20892397 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,14 @@ See the [Auth Plugins](docs/auth_plugins.md) documentation for even more informa See the [Handler Plugins](docs/handler_plugins.md) documentation for in-depth information about handler plugins and how you can create your own to extend the functionality of the Hooks framework for your own deployment. +### Lifecycle Plugins + +See the [Lifecycle Plugins](docs/lifecycle_plugins.md) documentation for information on how to create lifecycle plugins that can hook into the request/response/error lifecycle of the Hooks framework, allowing you to add custom behavior at various stages of processing webhook requests. + +### Instrument Plugins + +See the [Instrument Plugins](docs/instrument_plugins.md) documentation for information on how to create instrument plugins that can be used to collect metrics or report exceptions during webhook processing. These plugins can be used to integrate with monitoring and alerting systems. + ## Contributing 🤝 See the [Contributing](CONTRIBUTING.md) document for information on how to contribute to the Hooks project, including setting up your development environment, running tests, and releasing new versions. diff --git a/docs/instrument_plugins.md b/docs/instrument_plugins.md index 71f23695..3610582f 100644 --- a/docs/instrument_plugins.md +++ b/docs/instrument_plugins.md @@ -1,72 +1,35 @@ # Instrument Plugins -Instrument plugins provide global components for cross-cutting concerns like metrics collection and error reporting. The hooks framework includes two built-in instrument types: `stats` for metrics and `failbot` for error reporting. +Instrument plugins provide global components for cross-cutting concerns like metrics collection and error reporting. The hooks framework includes two built-in instrument types: `stats` for metrics and `failbot` for error reporting. By default, these instruments are no-op implementations that do not require any external dependencies. You can create custom implementations to integrate with your preferred monitoring and error reporting services. ## Overview By default, the framework provides no-op stub implementations that do nothing. This allows you to write code that calls instrument methods without requiring external dependencies. You can replace these stubs with real implementations that integrate with your monitoring and error reporting services. The instrument plugins are accessible throughout the entire application: + - In handlers via `stats` and `failbot` methods - In auth plugins via `stats` and `failbot` class methods - In lifecycle plugins via `stats` and `failbot` methods -## Built-in Instruments - -### Stats - -The stats instrument provides methods for metrics collection: - -```ruby -# Increment counters -stats.increment("webhook.processed", { handler: "MyHandler" }) - -# Record values -stats.record("webhook.payload_size", 1024, { event: "push" }) - -# Record timing manually -stats.timing("webhook.duration", 0.5, { handler: "MyHandler" }) - -# Measure execution time automatically -result = stats.measure("database.query", { table: "webhooks" }) do - # Database operation here - perform_database_query -end -``` - -### Failbot - -The failbot instrument provides methods for error reporting: +## Creating Custom Instruments -```ruby -# Report exceptions -begin - risky_operation -rescue => e - failbot.report(e, { context: "webhook_processing" }) -end +To create custom instrument implementations, inherit from the appropriate base class and implement the required methods. -# Report critical errors -failbot.critical("Database connection lost", { service: "postgres" }) +To actually have `stats` and `failbot` do something useful, you need to create custom classes that inherit from the base classes provided by the framework. Here’s an example of how to implement custom stats and failbot plugins. -# Report warnings -failbot.warning("Slow response time detected", { duration: 2.5 }) +You would then set the following attribute in your `hooks.yml` configuration file to point to these custom instrument plugins: -# Capture exceptions automatically -result = failbot.capture({ operation: "webhook_validation" }) do - validate_webhook_payload(payload) -end +```yaml +# hooks.yml +instruments_plugin_dir: ./plugins/instruments ``` -## Creating Custom Instruments - -To create custom instrument implementations, inherit from the appropriate base class and implement the required methods. - ### Custom Stats Implementation ```ruby -# custom_stats.rb -class CustomStats < Hooks::Plugins::Instruments::StatsBase +# plugins/instruments/stats.rb +class Stats < Hooks::Plugins::Instruments::StatsBase def initialize # Initialize your metrics client @client = MyMetricsService.new( @@ -107,8 +70,8 @@ end ### Custom Failbot Implementation ```ruby -# custom_failbot.rb -class CustomFailbot < Hooks::Plugins::Instruments::FailbotBase +# plugins/instruments/failbot.rb +class Failbot < Hooks::Plugins::Instruments::FailbotBase def initialize # Initialize your error reporting client @client = MyErrorService.new( @@ -168,11 +131,11 @@ lifecycle_plugin_dir: ./plugins/lifecycle Place your instrument plugin files in the specified directory: -``` +```text plugins/ └── instruments/ - ├── custom_stats.rb - └── custom_failbot.rb + ├── stats.rb + └── failbot.rb ``` ## File Naming and Class Detection @@ -183,11 +146,11 @@ The framework automatically detects which type of instrument you're creating bas - Classes inheriting from `FailbotBase` become the `failbot` instrument File naming follows snake_case to PascalCase conversion: -- `custom_stats.rb` → `CustomStats` -- `datadog_stats.rb` → `DatadogStats` + +- `stats.rb` → `stats` - `sentry_failbot.rb` → `SentryFailbot` -You can only have one stats plugin and one failbot plugin loaded. If multiple plugins of the same type are found, the last one loaded will be used. +You can only have one `stats` plugin and one `failbot` plugin loaded. If multiple plugins of the same type are found, the last one loaded will be used. ## Usage in Your Code @@ -364,4 +327,4 @@ expect(test_stats.recorded_metrics).to include( 4. **Validate configuration**: Check for required environment variables in `initialize` 5. **Document custom methods**: If you add methods beyond the base interface, document them 6. **Consider performance**: Instruments are called frequently, so keep operations fast -7. **Use connection pooling**: For high-throughput scenarios, use connection pooling for external services \ No newline at end of file +7. **Use connection pooling**: For high-throughput scenarios, use connection pooling for external services diff --git a/docs/lifecycle_plugins.md b/docs/lifecycle_plugins.md index 837a343f..f36e3709 100644 --- a/docs/lifecycle_plugins.md +++ b/docs/lifecycle_plugins.md @@ -104,20 +104,20 @@ end ```ruby def on_request(env) - # Increment counters + # Increment counters (example) stats.increment("webhook.requests", { event: env["HTTP_X_GITHUB_EVENT"] }) - # Record values + # Record values (example) stats.record("webhook.payload_size", env["CONTENT_LENGTH"].to_i) - # Measure execution time + # Measure execution time (example) stats.measure("webhook.processing", { handler: env["hooks.handler"] }) do # Processing happens in the handler end end def on_response(env, response) - # Record timing from environment + # Record timing from environment (example) stats.timing("webhook.duration", env["hooks.processing_time"]) end ``` @@ -126,22 +126,22 @@ end ```ruby def on_error(exception, env) - # Report errors with context + # Report errors with context (example) failbot.report(exception, { endpoint: env["PATH_INFO"], event_type: env["HTTP_X_GITHUB_EVENT"], handler: env["hooks.handler"] }) - # Report critical errors + # Report critical errors (example) failbot.critical("Handler crashed", { handler: env["hooks.handler"] }) - # Report warnings + # Report warnings (example) failbot.warning("Slow webhook processing", { duration: env["hooks.processing_time"] }) end def on_request(env) - # Capture and report exceptions during processing + # Capture and report exceptions during processing (example) failbot.capture({ context: "request_validation" }) do validate_webhook_signature(env) end @@ -161,7 +161,7 @@ auth_plugin_dir: ./plugins/auth Place your lifecycle plugin files in the specified directory: -``` +```text plugins/ └── lifecycle/ ├── metrics_lifecycle.rb @@ -252,4 +252,4 @@ end 2. **Handle errors gracefully**: Lifecycle plugins should not cause webhook processing to fail 3. **Use appropriate log levels**: Debug for detailed info, info for normal flow, warn for issues, error for failures 4. **Include relevant context**: Add useful tags and context to metrics and error reports -5. **Test thoroughly**: Lifecycle plugins run for every webhook request, so bugs have high impact \ No newline at end of file +5. **Test thoroughly**: Lifecycle plugins run for every webhook request, so bugs have high impact From cbb668938acf584b48aa1a2d32da0e6f307a1606 Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 21:01:33 -0700 Subject: [PATCH 21/28] add a custom failbot that works --- spec/acceptance/acceptance_tests.rb | 11 +++++++++++ spec/acceptance/config/endpoints/boomtown.yaml | 2 ++ spec/acceptance/plugins/handlers/boomtown.rb | 7 +++++++ spec/acceptance/plugins/instruments/failbot.rb | 11 +++++++++++ .../plugins/lifecycle/request_method_logger.rb | 5 +++++ 5 files changed, 36 insertions(+) create mode 100644 spec/acceptance/config/endpoints/boomtown.yaml create mode 100644 spec/acceptance/plugins/handlers/boomtown.rb create mode 100644 spec/acceptance/plugins/instruments/failbot.rb diff --git a/spec/acceptance/acceptance_tests.rb b/spec/acceptance/acceptance_tests.rb index 59311be2..a80246f4 100644 --- a/spec/acceptance/acceptance_tests.rb +++ b/spec/acceptance/acceptance_tests.rb @@ -180,5 +180,16 @@ expect(response.body).to include("authentication failed") end end + + describe "boomtown" do + it "sends a POST request to the /webhooks/boomtown endpoint and it explodes" do + payload = {}.to_json + headers = {} + response = http.post("/webhooks/boomtown", payload, headers) + + expect(response).to be_a(Net::HTTPInternalServerError) + expect(response.body).to include("Boomtown error occurred") + end + end end end diff --git a/spec/acceptance/config/endpoints/boomtown.yaml b/spec/acceptance/config/endpoints/boomtown.yaml new file mode 100644 index 00000000..af0bc05b --- /dev/null +++ b/spec/acceptance/config/endpoints/boomtown.yaml @@ -0,0 +1,2 @@ +path: /boomtown +handler: Boomtown diff --git a/spec/acceptance/plugins/handlers/boomtown.rb b/spec/acceptance/plugins/handlers/boomtown.rb new file mode 100644 index 00000000..9d9d2b14 --- /dev/null +++ b/spec/acceptance/plugins/handlers/boomtown.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Boomtown < Hooks::Plugins::Handlers::Base + def call(payload:, headers:, config:) + raise StandardError, "Boomtown error occurred" + end +end diff --git a/spec/acceptance/plugins/instruments/failbot.rb b/spec/acceptance/plugins/instruments/failbot.rb new file mode 100644 index 00000000..dad609e3 --- /dev/null +++ b/spec/acceptance/plugins/instruments/failbot.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Failbot < Hooks::Plugins::Instruments::FailbotBase + def initialize + # just a demo implementation + end + + def oh_no + log.error("oh no, something went wrong!") + end +end diff --git a/spec/acceptance/plugins/lifecycle/request_method_logger.rb b/spec/acceptance/plugins/lifecycle/request_method_logger.rb index 543f8945..1037f10a 100644 --- a/spec/acceptance/plugins/lifecycle/request_method_logger.rb +++ b/spec/acceptance/plugins/lifecycle/request_method_logger.rb @@ -5,4 +5,9 @@ class RequestMethodLogger < Hooks::Plugins::Lifecycle def on_request(env) log.debug("on_request called with method: #{env['REQUEST_METHOD']}") end + + def on_error(error, env) + log.error("on_error called with error: #{error.message}") + failbot.oh_no + end end From 50ddead93e3adf379708c62eb87fb462d0161fe4 Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 21:12:52 -0700 Subject: [PATCH 22/28] fix: enhance logging for webhook event processing and add start time to context --- lib/hooks/app/api.rb | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/hooks/app/api.rb b/lib/hooks/app/api.rb index acf74f2a..0d43f1df 100644 --- a/lib/hooks/app/api.rb +++ b/lib/hooks/app/api.rb @@ -76,7 +76,9 @@ def self.create(config:, endpoints:, log:) "REMOTE_ADDR" => request.env["REMOTE_ADDR"], "hooks.request_id" => request_id, "hooks.handler" => handler_class_name, - "hooks.endpoint_config" => endpoint_config + "hooks.endpoint_config" => endpoint_config, + "hooks.start_time" => start_time.iso8601, + "hooks.full_path" => full_path } # Add HTTP headers to environment @@ -113,12 +115,12 @@ def self.create(config:, endpoints:, log:) plugin.on_response(rack_env, response) end - log.info "request processed successfully by handler: #{handler_class_name}" - log.debug "request duration: #{Time.now - start_time}s" + log.info("successfully processed webhook event with handler: #{handler_class_name}") + log.debug("processing duration: #{Time.now - start_time}s") status 200 content_type "application/json" response.to_json - rescue => e + rescue StandardError => e # Call lifecycle hooks: on_error if defined?(rack_env) Core::PluginLoader.lifecycle_plugins.each do |plugin| @@ -126,7 +128,7 @@ def self.create(config:, endpoints:, log:) end end - log.error "request failed: #{e.message}" + log.error("an error occuring during the processing of a webhook event - #{e.message}") error_response = { error: e.message, code: determine_error_code(e), From 4a2b81ecc94f9da995b6b742cd623b1f71d44054 Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 21:14:24 -0700 Subject: [PATCH 23/28] fix: remove grape-swagger dependency and update Gemfile.lock --- Gemfile | 1 + Gemfile.lock | 5 +---- hooks.gemspec | 1 - vendor/cache/grape-swagger-2.1.2.gem | Bin 48128 -> 0 bytes 4 files changed, 2 insertions(+), 5 deletions(-) delete mode 100644 vendor/cache/grape-swagger-2.1.2.gem diff --git a/Gemfile b/Gemfile index dd1d2a5d..02fad98f 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,7 @@ gemspec group :development do gem "irb", "~> 1" + gem "rack-test", "~> 2.2" gem "rspec", "~> 3" gem "rubocop", "~> 1" gem "rubocop-github", "~> 0.26" diff --git a/Gemfile.lock b/Gemfile.lock index eba3c056..fdfbcaf0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,7 +4,6 @@ PATH hooks-ruby (0.0.2) dry-schema (~> 1.14, >= 1.14.1) grape (~> 2.3) - grape-swagger (~> 2.1, >= 2.1.2) puma (~> 6.6) redacting-logger (~> 1.5) retryable (~> 3.0, >= 3.0.5) @@ -76,9 +75,6 @@ GEM mustermann-grape (~> 1.1.0) rack (>= 2) zeitwerk - grape-swagger (2.1.2) - grape (>= 1.7, < 3.0) - rack-test (~> 2) hashdiff (1.2.0) i18n (1.14.7) concurrent-ruby (~> 1.0) @@ -225,6 +221,7 @@ PLATFORMS DEPENDENCIES hooks-ruby! irb (~> 1) + rack-test (~> 2.2) rspec (~> 3) rubocop (~> 1) rubocop-github (~> 0.26) diff --git a/hooks.gemspec b/hooks.gemspec index 61b27710..731d36ce 100644 --- a/hooks.gemspec +++ b/hooks.gemspec @@ -22,7 +22,6 @@ Gem::Specification.new do |spec| spec.add_dependency "retryable", "~> 3.0", ">= 3.0.5" spec.add_dependency "dry-schema", "~> 1.14", ">= 1.14.1" spec.add_dependency "grape", "~> 2.3" - spec.add_dependency "grape-swagger", "~> 2.1", ">= 2.1.2" spec.add_dependency "puma", "~> 6.6" spec.required_ruby_version = Gem::Requirement.new(">= 3.2.2") diff --git a/vendor/cache/grape-swagger-2.1.2.gem b/vendor/cache/grape-swagger-2.1.2.gem deleted file mode 100644 index 0d39c0faa0e9e56642678e954e70c3cb85574dca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 48128 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 T{$u}%z<(m}p9uVa6@mW&hE7<; From 762fb91e47fa8335aafe66e568b3e780d50c1368 Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 21:26:34 -0700 Subject: [PATCH 24/28] refactor: remove unused methods from Failbot and Stats instrument classes --- lib/hooks/plugins/instruments/failbot.rb | 27 +---------- lib/hooks/plugins/instruments/failbot_base.rb | 41 ----------------- lib/hooks/plugins/instruments/stats.rb | 29 +----------- lib/hooks/plugins/instruments/stats_base.rb | 45 ------------------- .../plugins/instruments/failbot_base_spec.rb | 44 ------------------ .../hooks/plugins/instruments/failbot_spec.rb | 39 +--------------- .../plugins/instruments/stats_base_spec.rb | 30 ------------- .../hooks/plugins/instruments/stats_spec.rb | 29 +----------- 8 files changed, 4 insertions(+), 280 deletions(-) diff --git a/lib/hooks/plugins/instruments/failbot.rb b/lib/hooks/plugins/instruments/failbot.rb index a0a8681b..bf324423 100644 --- a/lib/hooks/plugins/instruments/failbot.rb +++ b/lib/hooks/plugins/instruments/failbot.rb @@ -11,32 +11,7 @@ module Instruments # Users can replace this with their own implementation for services # like Sentry, Rollbar, etc. class Failbot < FailbotBase - # Report an error or exception - # - # @param error_or_message [Exception, String] Exception object or error message - # @param context [Hash] Optional context information - # @return [void] - def report(error_or_message, context = {}) - # Override in subclass for actual error reporting - end - - # Report a critical error - # - # @param error_or_message [Exception, String] Exception object or error message - # @param context [Hash] Optional context information - # @return [void] - def critical(error_or_message, context = {}) - # Override in subclass for actual error reporting - end - - # Report a warning - # - # @param message [String] Warning message - # @param context [Hash] Optional context information - # @return [void] - def warning(message, context = {}) - # Override in subclass for actual warning reporting - end + # Inherit from FailbotBase to provide a default implementation of the failbot instrument. end end end diff --git a/lib/hooks/plugins/instruments/failbot_base.rb b/lib/hooks/plugins/instruments/failbot_base.rb index a6c8fd53..986d3e8a 100644 --- a/lib/hooks/plugins/instruments/failbot_base.rb +++ b/lib/hooks/plugins/instruments/failbot_base.rb @@ -19,47 +19,6 @@ class FailbotBase def log Hooks::Log.instance end - - # Report an error or exception - # - # @param error_or_message [Exception, String] Exception object or error message - # @param context [Hash] Optional context information - # @return [void] - # @raise [NotImplementedError] if not implemented by subclass - def report(error_or_message, context = {}) - raise NotImplementedError, "Failbot instrument must implement #report method" - end - - # Report a critical error - # - # @param error_or_message [Exception, String] Exception object or error message - # @param context [Hash] Optional context information - # @return [void] - # @raise [NotImplementedError] if not implemented by subclass - def critical(error_or_message, context = {}) - raise NotImplementedError, "Failbot instrument must implement #critical method" - end - - # Report a warning - # - # @param message [String] Warning message - # @param context [Hash] Optional context information - # @return [void] - # @raise [NotImplementedError] if not implemented by subclass - def warning(message, context = {}) - raise NotImplementedError, "Failbot instrument must implement #warning method" - end - - # Capture an exception during block execution - # - # @param context [Hash] Optional context information - # @return [Object] Return value of the block - def capture(context = {}) - yield - rescue => e - report(e, context) - raise - end end end end diff --git a/lib/hooks/plugins/instruments/stats.rb b/lib/hooks/plugins/instruments/stats.rb index 49107963..058c8911 100644 --- a/lib/hooks/plugins/instruments/stats.rb +++ b/lib/hooks/plugins/instruments/stats.rb @@ -11,34 +11,7 @@ module Instruments # Users can replace this with their own implementation for services # like DataDog, New Relic, etc. class Stats < StatsBase - # Record a metric - # - # @param metric_name [String] Name of the metric - # @param value [Numeric] Value to record - # @param tags [Hash] Optional tags/labels for the metric - # @return [void] - def record(metric_name, value, tags = {}) - # Override in subclass for actual metrics reporting - end - - # Increment a counter - # - # @param metric_name [String] Name of the counter - # @param tags [Hash] Optional tags/labels for the metric - # @return [void] - def increment(metric_name, tags = {}) - # Override in subclass for actual metrics reporting - end - - # Record a timing metric - # - # @param metric_name [String] Name of the timing metric - # @param duration [Numeric] Duration in seconds - # @param tags [Hash] Optional tags/labels for the metric - # @return [void] - def timing(metric_name, duration, tags = {}) - # Override in subclass for actual metrics reporting - end + # Inherit from StatsBase to provide a default implementation of the stats instrument. end end end diff --git a/lib/hooks/plugins/instruments/stats_base.rb b/lib/hooks/plugins/instruments/stats_base.rb index 832bbd67..8f83cc77 100644 --- a/lib/hooks/plugins/instruments/stats_base.rb +++ b/lib/hooks/plugins/instruments/stats_base.rb @@ -19,51 +19,6 @@ class StatsBase def log Hooks::Log.instance end - - # Record a metric - # - # @param metric_name [String] Name of the metric - # @param value [Numeric] Value to record - # @param tags [Hash] Optional tags/labels for the metric - # @return [void] - # @raise [NotImplementedError] if not implemented by subclass - def record(metric_name, value, tags = {}) - raise NotImplementedError, "Stats instrument must implement #record method" - end - - # Increment a counter - # - # @param metric_name [String] Name of the counter - # @param tags [Hash] Optional tags/labels for the metric - # @return [void] - # @raise [NotImplementedError] if not implemented by subclass - def increment(metric_name, tags = {}) - raise NotImplementedError, "Stats instrument must implement #increment method" - end - - # Record a timing metric - # - # @param metric_name [String] Name of the timing metric - # @param duration [Numeric] Duration in seconds - # @param tags [Hash] Optional tags/labels for the metric - # @return [void] - # @raise [NotImplementedError] if not implemented by subclass - def timing(metric_name, duration, tags = {}) - raise NotImplementedError, "Stats instrument must implement #timing method" - end - - # Measure execution time of a block - # - # @param metric_name [String] Name of the timing metric - # @param tags [Hash] Optional tags/labels for the metric - # @return [Object] Return value of the block - def measure(metric_name, tags = {}) - start_time = Time.now - result = yield - duration = Time.now - start_time - timing(metric_name, duration, tags) - result - end end end end diff --git a/spec/unit/lib/hooks/plugins/instruments/failbot_base_spec.rb b/spec/unit/lib/hooks/plugins/instruments/failbot_base_spec.rb index a3b2475a..42aaf633 100644 --- a/spec/unit/lib/hooks/plugins/instruments/failbot_base_spec.rb +++ b/spec/unit/lib/hooks/plugins/instruments/failbot_base_spec.rb @@ -9,48 +9,4 @@ expect(failbot.log).to eq(Hooks::Log.instance) end end - - describe "#report" do - it "raises NotImplementedError" do - expect { failbot.report("error", {}) }.to raise_error(NotImplementedError, "Failbot instrument must implement #report method") - end - end - - describe "#critical" do - it "raises NotImplementedError" do - expect { failbot.critical("critical error", {}) }.to raise_error(NotImplementedError, "Failbot instrument must implement #critical method") - end - end - - describe "#warning" do - it "raises NotImplementedError" do - expect { failbot.warning("warning message", {}) }.to raise_error(NotImplementedError, "Failbot instrument must implement #warning method") - end - end - - describe "#capture" do - it "yields block and captures exceptions" do - allow(failbot).to receive(:report) - - result = failbot.capture({ context: "test" }) do - "block_result" - end - - expect(result).to eq("block_result") - expect(failbot).not_to have_received(:report) - end - - it "captures and re-raises exceptions" do - error = StandardError.new("test error") - allow(failbot).to receive(:report) - - expect do - failbot.capture({ context: "test" }) do - raise error - end - end.to raise_error(error) - - expect(failbot).to have_received(:report).with(error, { context: "test" }) - end - end end diff --git a/spec/unit/lib/hooks/plugins/instruments/failbot_spec.rb b/spec/unit/lib/hooks/plugins/instruments/failbot_spec.rb index e6bb1302..3c8e9e09 100644 --- a/spec/unit/lib/hooks/plugins/instruments/failbot_spec.rb +++ b/spec/unit/lib/hooks/plugins/instruments/failbot_spec.rb @@ -5,43 +5,6 @@ it "inherits from FailbotBase" do expect(described_class).to be < Hooks::Plugins::Instruments::FailbotBase - end - - describe "#report" do - it "does nothing by default" do - expect { failbot.report("error", {}) }.not_to raise_error - end - end - - describe "#critical" do - it "does nothing by default" do - expect { failbot.critical("critical error", {}) }.not_to raise_error - end - end - - describe "#warning" do - it "does nothing by default" do - expect { failbot.warning("warning message", {}) }.not_to raise_error - end - end - - describe "#capture" do - it "yields block and does nothing on success" do - result = failbot.capture({ context: "test" }) do - "block_result" - end - - expect(result).to eq("block_result") - end - - it "captures but does nothing with exceptions" do - error = StandardError.new("test error") - - expect do - failbot.capture({ context: "test" }) do - raise error - end - end.to raise_error(error) - end + expect(failbot).to be_a(Hooks::Plugins::Instruments::FailbotBase) end end diff --git a/spec/unit/lib/hooks/plugins/instruments/stats_base_spec.rb b/spec/unit/lib/hooks/plugins/instruments/stats_base_spec.rb index 21586f58..7294aaee 100644 --- a/spec/unit/lib/hooks/plugins/instruments/stats_base_spec.rb +++ b/spec/unit/lib/hooks/plugins/instruments/stats_base_spec.rb @@ -9,34 +9,4 @@ expect(stats.log).to eq(Hooks::Log.instance) end end - - describe "#record" do - it "raises NotImplementedError" do - expect { stats.record("metric", 1.0, {}) }.to raise_error(NotImplementedError, "Stats instrument must implement #record method") - end - end - - describe "#increment" do - it "raises NotImplementedError" do - expect { stats.increment("counter", {}) }.to raise_error(NotImplementedError, "Stats instrument must implement #increment method") - end - end - - describe "#timing" do - it "raises NotImplementedError" do - expect { stats.timing("timer", 0.5, {}) }.to raise_error(NotImplementedError, "Stats instrument must implement #timing method") - end - end - - describe "#measure" do - it "measures execution time and calls timing" do - allow(stats).to receive(:timing) - result = stats.measure("test_metric", { tag: "value" }) do - "block_result" - end - - expect(result).to eq("block_result") - expect(stats).to have_received(:timing).with("test_metric", kind_of(Numeric), { tag: "value" }) - end - end end diff --git a/spec/unit/lib/hooks/plugins/instruments/stats_spec.rb b/spec/unit/lib/hooks/plugins/instruments/stats_spec.rb index 3d85f3fa..49545429 100644 --- a/spec/unit/lib/hooks/plugins/instruments/stats_spec.rb +++ b/spec/unit/lib/hooks/plugins/instruments/stats_spec.rb @@ -5,33 +5,6 @@ it "inherits from StatsBase" do expect(described_class).to be < Hooks::Plugins::Instruments::StatsBase - end - - describe "#record" do - it "does nothing by default" do - expect { stats.record("metric", 1.0, {}) }.not_to raise_error - end - end - - describe "#increment" do - it "does nothing by default" do - expect { stats.increment("counter", {}) }.not_to raise_error - end - end - - describe "#timing" do - it "does nothing by default" do - expect { stats.timing("timer", 0.5, {}) }.not_to raise_error - end - end - - describe "#measure" do - it "still works for measuring execution time" do - result = stats.measure("test_metric", { tag: "value" }) do - "block_result" - end - - expect(result).to eq("block_result") - end + expect(stats).to be_a(Hooks::Plugins::Instruments::StatsBase) end end From a0495d4e78b86c62b3e12248ede4a0bde2937e46 Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 21:28:32 -0700 Subject: [PATCH 25/28] bump coverage --- spec/unit/required_coverage_percentage.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/unit/required_coverage_percentage.rb b/spec/unit/required_coverage_percentage.rb index 13a026cf..4538414e 100644 --- a/spec/unit/required_coverage_percentage.rb +++ b/spec/unit/required_coverage_percentage.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -REQUIRED_COVERAGE_PERCENTAGE = 85 +REQUIRED_COVERAGE_PERCENTAGE = 87 From 658107ec7eb1e9b47782867bf68a14e1b0a2e7e4 Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 21:32:25 -0700 Subject: [PATCH 26/28] refactor: improve comments and simplify request context in CatchallEndpoint --- lib/hooks/app/endpoints/catchall.rb | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/hooks/app/endpoints/catchall.rb b/lib/hooks/app/endpoints/catchall.rb index e26ccc3a..0df4be5e 100644 --- a/lib/hooks/app/endpoints/catchall.rb +++ b/lib/hooks/app/endpoints/catchall.rb @@ -1,5 +1,13 @@ # frozen_string_literal: true +# !!! IMPORTANT !!! +# This file handles the catchall endpoint for the Hooks application. +# You should not be using catchall endpoints in production. +# This is mainly for development, testing, and demo purposes. +# The logging is limited, lifecycle hooks are not called, +# and it does not support plugins or instruments. +# Use with caution! + require "grape" require_relative "../../plugins/handlers/default" require_relative "../helpers" @@ -23,7 +31,7 @@ def self.route_block(captured_config, captured_logger) # Set request context for logging request_context = { - request_id: request_id, + request_id:, path: "/#{params[:path]}", handler: "DefaultHandler" } @@ -45,8 +53,8 @@ def self.route_block(captured_config, captured_logger) # Call handler response = handler.call( - payload: payload, - headers: headers, + payload:, + headers:, config: {} ) From 89d4f159c1faae27499480f2460acb7fa6242033 Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 21:34:00 -0700 Subject: [PATCH 27/28] adding nocov blocks and 90% coverage --- lib/hooks/app/endpoints/catchall.rb | 4 ++++ spec/unit/required_coverage_percentage.rb | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/hooks/app/endpoints/catchall.rb b/lib/hooks/app/endpoints/catchall.rb index 0df4be5e..eb3eb777 100644 --- a/lib/hooks/app/endpoints/catchall.rb +++ b/lib/hooks/app/endpoints/catchall.rb @@ -18,10 +18,13 @@ class CatchallEndpoint < Grape::API include Hooks::App::Helpers def self.mount_path(config) + # :nocov: "#{config[:root_path]}/*path" + # :nocov: end def self.route_block(captured_config, captured_logger) + # :nocov: proc do request_id = uuid @@ -86,6 +89,7 @@ def self.route_block(captured_config, captured_logger) end end end + # :nocov: end end end diff --git a/spec/unit/required_coverage_percentage.rb b/spec/unit/required_coverage_percentage.rb index 4538414e..d11ab8af 100644 --- a/spec/unit/required_coverage_percentage.rb +++ b/spec/unit/required_coverage_percentage.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -REQUIRED_COVERAGE_PERCENTAGE = 87 +REQUIRED_COVERAGE_PERCENTAGE = 90 From feefdd7f9e5fb3324e1ed549e72d30a24a661cd0 Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Wed, 11 Jun 2025 21:35:49 -0700 Subject: [PATCH 28/28] bump version --- Gemfile.lock | 2 +- lib/hooks/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index fdfbcaf0..932eb34e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - hooks-ruby (0.0.2) + hooks-ruby (0.0.3) dry-schema (~> 1.14, >= 1.14.1) grape (~> 2.3) puma (~> 6.6) diff --git a/lib/hooks/version.rb b/lib/hooks/version.rb index cf93033a..f02ec502 100644 --- a/lib/hooks/version.rb +++ b/lib/hooks/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Hooks - VERSION = "0.0.2" + VERSION = "0.0.3" end