Skip to content

Commit 5340e15

Browse files
CopilotGrantBirki
andcommitted
Implement global lifecycle hooks and stats/failbot components
Co-authored-by: GrantBirki <[email protected]>
1 parent 9473701 commit 5340e15

File tree

17 files changed

+1181
-4
lines changed

17 files changed

+1181
-4
lines changed

lib/hooks.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
require_relative "hooks/version"
44
require_relative "hooks/core/builder"
55

6+
# Load all core components
7+
Dir[File.join(__dir__, "hooks/core/**/*.rb")].sort.each do |file|
8+
require file
9+
end
10+
611
# Load all plugins (auth plugins, handler plugins, lifecycle hooks, etc.)
712
Dir[File.join(__dir__, "hooks/plugins/**/*.rb")].sort.each do |file|
813
require file

lib/hooks/app/api.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
require_relative "../plugins/handlers/default"
1010
require_relative "../core/logger_factory"
1111
require_relative "../core/log"
12+
require_relative "../core/plugin_loader"
1213

1314
# Import all core endpoint classes dynamically
1415
Dir[File.join(__dir__, "endpoints/**/*.rb")].sort.each { |file| require file }
@@ -63,6 +64,34 @@ def self.create(config:, endpoints:, log:)
6364
# ex: Hooks::Log.info("message") will include request_id, path, handler, etc
6465
Core::LogContext.with(request_context) do
6566
begin
67+
# Build Rack environment for lifecycle hooks
68+
rack_env = {
69+
"REQUEST_METHOD" => request.request_method,
70+
"PATH_INFO" => request.path_info,
71+
"QUERY_STRING" => request.query_string,
72+
"HTTP_VERSION" => request.env["HTTP_VERSION"],
73+
"REQUEST_URI" => request.url,
74+
"SERVER_NAME" => request.env["SERVER_NAME"],
75+
"SERVER_PORT" => request.env["SERVER_PORT"],
76+
"CONTENT_TYPE" => request.content_type,
77+
"CONTENT_LENGTH" => request.content_length,
78+
"REMOTE_ADDR" => request.env["REMOTE_ADDR"],
79+
"hooks.request_id" => request_id,
80+
"hooks.handler" => handler_class_name,
81+
"hooks.endpoint_config" => endpoint_config
82+
}
83+
84+
# Add HTTP headers to environment
85+
headers.each do |key, value|
86+
env_key = "HTTP_#{key.upcase.tr('-', '_')}"
87+
rack_env[env_key] = value
88+
end
89+
90+
# Call lifecycle hooks: on_request
91+
Core::PluginLoader.lifecycle_plugins.each do |plugin|
92+
plugin.on_request(rack_env)
93+
end
94+
6695
enforce_request_limits(config)
6796
request.body.rewind
6897
raw_body = request.body.read
@@ -81,12 +110,24 @@ def self.create(config:, endpoints:, log:)
81110
config: endpoint_config
82111
)
83112

113+
# Call lifecycle hooks: on_response
114+
Core::PluginLoader.lifecycle_plugins.each do |plugin|
115+
plugin.on_response(rack_env, response)
116+
end
117+
84118
log.info "request processed successfully by handler: #{handler_class_name}"
85119
log.debug "request duration: #{Time.now - start_time}s"
86120
status 200
87121
content_type "application/json"
88122
response.to_json
89123
rescue => e
124+
# Call lifecycle hooks: on_error
125+
if defined?(rack_env)
126+
Core::PluginLoader.lifecycle_plugins.each do |plugin|
127+
plugin.on_error(e, rack_env)
128+
end
129+
end
130+
90131
log.error "request failed: #{e.message}"
91132
error_response = {
92133
error: e.message,

lib/hooks/core/failbot.rb

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# frozen_string_literal: true
2+
3+
module Hooks
4+
module Core
5+
# Global failbot component for error reporting
6+
#
7+
# This is a stub implementation that does nothing by default.
8+
# Users can replace this with their own implementation for services
9+
# like Sentry, Rollbar, etc.
10+
class Failbot
11+
# Report an error or exception
12+
#
13+
# @param error_or_message [Exception, String] Exception object or error message
14+
# @param context [Hash] Optional context information
15+
# @return [void]
16+
def report(error_or_message, context = {})
17+
# Override in subclass for actual error reporting
18+
end
19+
20+
# Report a critical error
21+
#
22+
# @param error_or_message [Exception, String] Exception object or error message
23+
# @param context [Hash] Optional context information
24+
# @return [void]
25+
def critical(error_or_message, context = {})
26+
# Override in subclass for actual error reporting
27+
end
28+
29+
# Report a warning
30+
#
31+
# @param message [String] Warning message
32+
# @param context [Hash] Optional context information
33+
# @return [void]
34+
def warning(message, context = {})
35+
# Override in subclass for actual warning reporting
36+
end
37+
38+
# Capture an exception during block execution
39+
#
40+
# @param context [Hash] Optional context information
41+
# @return [Object] Return value of the block
42+
def capture(context = {})
43+
yield
44+
rescue => e
45+
report(e, context)
46+
raise
47+
end
48+
end
49+
end
50+
end
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "stats"
4+
require_relative "failbot"
5+
6+
module Hooks
7+
module Core
8+
# Global registry for shared components accessible throughout the application
9+
class GlobalComponents
10+
@stats = Stats.new
11+
@failbot = Failbot.new
12+
13+
class << self
14+
attr_accessor :stats, :failbot
15+
16+
end
17+
18+
# Reset components to default instances (for testing)
19+
#
20+
# @return [void]
21+
def self.reset
22+
@stats = Stats.new
23+
@failbot = Failbot.new
24+
end
25+
end
26+
end
27+
end

lib/hooks/core/plugin_loader.rb

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55

66
module Hooks
77
module Core
8-
# Loads and caches all plugins (auth + handlers) at boot time
8+
# Loads and caches all plugins (auth + handlers + lifecycle) at boot time
99
class PluginLoader
1010
# Class-level registries for loaded plugins
1111
@auth_plugins = {}
1212
@handler_plugins = {}
13+
@lifecycle_plugins = []
1314

1415
class << self
15-
attr_reader :auth_plugins, :handler_plugins
16+
attr_reader :auth_plugins, :handler_plugins, :lifecycle_plugins
1617

1718
# Load all plugins at boot time
1819
#
@@ -22,13 +23,15 @@ def load_all_plugins(config)
2223
# Clear existing registries
2324
@auth_plugins = {}
2425
@handler_plugins = {}
26+
@lifecycle_plugins = []
2527

2628
# Load built-in plugins first
2729
load_builtin_plugins
2830

2931
# Load custom plugins if directories are configured
3032
load_custom_auth_plugins(config[:auth_plugin_dir]) if config[:auth_plugin_dir]
3133
load_custom_handler_plugins(config[:handler_plugin_dir]) if config[:handler_plugin_dir]
34+
load_custom_lifecycle_plugins(config[:lifecycle_plugin_dir]) if config[:lifecycle_plugin_dir]
3235

3336
# Log loaded plugins
3437
log_loaded_plugins
@@ -71,6 +74,7 @@ def get_handler_plugin(handler_name)
7174
def clear_plugins
7275
@auth_plugins = {}
7376
@handler_plugins = {}
77+
@lifecycle_plugins = []
7478
end
7579

7680
private
@@ -119,6 +123,22 @@ def load_custom_handler_plugins(handler_plugin_dir)
119123
end
120124
end
121125

126+
# Load custom lifecycle plugins from directory
127+
#
128+
# @param lifecycle_plugin_dir [String] Directory containing custom lifecycle plugins
129+
# @return [void]
130+
def load_custom_lifecycle_plugins(lifecycle_plugin_dir)
131+
return unless lifecycle_plugin_dir && Dir.exist?(lifecycle_plugin_dir)
132+
133+
Dir.glob(File.join(lifecycle_plugin_dir, "*.rb")).sort.each do |file_path|
134+
begin
135+
load_custom_lifecycle_plugin(file_path, lifecycle_plugin_dir)
136+
rescue => e
137+
raise StandardError, "Failed to load lifecycle plugin from #{file_path}: #{e.message}"
138+
end
139+
end
140+
end
141+
122142
# Load a single custom auth plugin file
123143
#
124144
# @param file_path [String] Path to the auth plugin file
@@ -189,6 +209,41 @@ def load_custom_handler_plugin(file_path, handler_plugin_dir)
189209
@handler_plugins[class_name] = handler_class
190210
end
191211

212+
# Load a single custom lifecycle plugin file
213+
#
214+
# @param file_path [String] Path to the lifecycle plugin file
215+
# @param lifecycle_plugin_dir [String] Base directory for lifecycle plugins
216+
# @return [void]
217+
def load_custom_lifecycle_plugin(file_path, lifecycle_plugin_dir)
218+
# Security: Ensure the file path doesn't escape the lifecycle plugin directory
219+
normalized_lifecycle_dir = Pathname.new(File.expand_path(lifecycle_plugin_dir))
220+
normalized_file_path = Pathname.new(File.expand_path(file_path))
221+
unless normalized_file_path.descend.any? { |path| path == normalized_lifecycle_dir }
222+
raise SecurityError, "Lifecycle plugin path outside of lifecycle plugin directory: #{file_path}"
223+
end
224+
225+
# Extract class name from file (e.g., logging_lifecycle.rb -> LoggingLifecycle)
226+
file_name = File.basename(file_path, ".rb")
227+
class_name = file_name.split("_").map(&:capitalize).join("")
228+
229+
# Security: Validate class name
230+
unless valid_lifecycle_class_name?(class_name)
231+
raise StandardError, "Invalid lifecycle plugin class name: #{class_name}"
232+
end
233+
234+
# Load the file
235+
require file_path
236+
237+
# Get the class and validate it
238+
lifecycle_class = Object.const_get(class_name)
239+
unless lifecycle_class < Hooks::Plugins::Lifecycle
240+
raise StandardError, "Lifecycle plugin class must inherit from Hooks::Plugins::Lifecycle: #{class_name}"
241+
end
242+
243+
# Register the plugin instance
244+
@lifecycle_plugins << lifecycle_class.new
245+
end
246+
192247
# Log summary of loaded plugins
193248
#
194249
# @return [void]
@@ -201,6 +256,7 @@ def log_loaded_plugins
201256

202257
log.info "Loaded #{@auth_plugins.size} auth plugins: #{@auth_plugins.keys.join(', ')}"
203258
log.info "Loaded #{@handler_plugins.size} handler plugins: #{@handler_plugins.keys.join(', ')}"
259+
log.info "Loaded #{@lifecycle_plugins.size} lifecycle plugins"
204260
end
205261

206262
# Validate that an auth plugin class name is safe to load
@@ -244,6 +300,27 @@ def valid_handler_class_name?(class_name)
244300

245301
true
246302
end
303+
304+
# Validate that a lifecycle plugin class name is safe to load
305+
#
306+
# @param class_name [String] The class name to validate
307+
# @return [Boolean] true if the class name is safe, false otherwise
308+
def valid_lifecycle_class_name?(class_name)
309+
# Must be a string
310+
return false unless class_name.is_a?(String)
311+
312+
# Must not be empty or only whitespace
313+
return false if class_name.strip.empty?
314+
315+
# Must match a safe pattern: alphanumeric + underscore, starting with uppercase
316+
# Examples: LoggingLifecycle, MetricsLifecycle, CustomLifecycle
317+
return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
318+
319+
# Must not be a system/built-in class name
320+
return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name)
321+
322+
true
323+
end
247324
end
248325
end
249326
end

lib/hooks/core/stats.rb

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# frozen_string_literal: true
2+
3+
module Hooks
4+
module Core
5+
# Global stats component for metrics reporting
6+
#
7+
# This is a stub implementation that does nothing by default.
8+
# Users can replace this with their own implementation for services
9+
# like DataDog, New Relic, etc.
10+
class Stats
11+
# Record a metric
12+
#
13+
# @param metric_name [String] Name of the metric
14+
# @param value [Numeric] Value to record
15+
# @param tags [Hash] Optional tags/labels for the metric
16+
# @return [void]
17+
def record(metric_name, value, tags = {})
18+
# Override in subclass for actual metrics reporting
19+
end
20+
21+
# Increment a counter
22+
#
23+
# @param metric_name [String] Name of the counter
24+
# @param tags [Hash] Optional tags/labels for the metric
25+
# @return [void]
26+
def increment(metric_name, tags = {})
27+
# Override in subclass for actual metrics reporting
28+
end
29+
30+
# Record a timing metric
31+
#
32+
# @param metric_name [String] Name of the timing metric
33+
# @param duration [Numeric] Duration in seconds
34+
# @param tags [Hash] Optional tags/labels for the metric
35+
# @return [void]
36+
def timing(metric_name, duration, tags = {})
37+
# Override in subclass for actual metrics reporting
38+
end
39+
40+
# Measure execution time of a block
41+
#
42+
# @param metric_name [String] Name of the timing metric
43+
# @param tags [Hash] Optional tags/labels for the metric
44+
# @return [Object] Return value of the block
45+
def measure(metric_name, tags = {})
46+
start_time = Time.now
47+
result = yield
48+
duration = Time.now - start_time
49+
timing(metric_name, duration, tags)
50+
result
51+
end
52+
end
53+
end
54+
end

lib/hooks/plugins/auth/base.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require "rack/utils"
44
require_relative "../../core/log"
5+
require_relative "../../core/global_components"
56

67
module Hooks
78
module Plugins
@@ -33,6 +34,30 @@ def self.log
3334
Hooks::Log.instance
3435
end
3536

37+
# Global stats component accessor
38+
# @return [Hooks::Core::Stats] Stats instance for metrics reporting
39+
#
40+
# Provides access to the global stats component for reporting metrics
41+
# to services like DataDog, New Relic, etc.
42+
#
43+
# @example Recording a metric in an inherited class
44+
# stats.increment("auth.validation", { plugin: "hmac" })
45+
def self.stats
46+
Hooks::Core::GlobalComponents.stats
47+
end
48+
49+
# Global failbot component accessor
50+
# @return [Hooks::Core::Failbot] Failbot instance for error reporting
51+
#
52+
# Provides access to the global failbot component for reporting errors
53+
# to services like Sentry, Rollbar, etc.
54+
#
55+
# @example Reporting an error in an inherited class
56+
# failbot.report("Auth validation failed", { plugin: "hmac" })
57+
def self.failbot
58+
Hooks::Core::GlobalComponents.failbot
59+
end
60+
3661
# Retrieve the secret from the environment variable based on the key set in the configuration
3762
#
3863
# Note: This method is intended to be used by subclasses

0 commit comments

Comments
 (0)