findbug 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +8 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +375 -0
  6. data/Rakefile +12 -0
  7. data/app/controllers/findbug/application_controller.rb +105 -0
  8. data/app/controllers/findbug/dashboard_controller.rb +93 -0
  9. data/app/controllers/findbug/errors_controller.rb +129 -0
  10. data/app/controllers/findbug/performance_controller.rb +80 -0
  11. data/app/jobs/findbug/alert_job.rb +40 -0
  12. data/app/jobs/findbug/cleanup_job.rb +132 -0
  13. data/app/jobs/findbug/persist_job.rb +158 -0
  14. data/app/models/findbug/error_event.rb +197 -0
  15. data/app/models/findbug/performance_event.rb +237 -0
  16. data/app/views/findbug/dashboard/index.html.erb +199 -0
  17. data/app/views/findbug/errors/index.html.erb +137 -0
  18. data/app/views/findbug/errors/show.html.erb +185 -0
  19. data/app/views/findbug/performance/index.html.erb +168 -0
  20. data/app/views/findbug/performance/show.html.erb +203 -0
  21. data/app/views/layouts/findbug/application.html.erb +601 -0
  22. data/lib/findbug/alerts/channels/base.rb +75 -0
  23. data/lib/findbug/alerts/channels/discord.rb +155 -0
  24. data/lib/findbug/alerts/channels/email.rb +179 -0
  25. data/lib/findbug/alerts/channels/slack.rb +149 -0
  26. data/lib/findbug/alerts/channels/webhook.rb +143 -0
  27. data/lib/findbug/alerts/dispatcher.rb +126 -0
  28. data/lib/findbug/alerts/throttler.rb +110 -0
  29. data/lib/findbug/background_persister.rb +142 -0
  30. data/lib/findbug/capture/context.rb +301 -0
  31. data/lib/findbug/capture/exception_handler.rb +141 -0
  32. data/lib/findbug/capture/exception_subscriber.rb +228 -0
  33. data/lib/findbug/capture/message_handler.rb +104 -0
  34. data/lib/findbug/capture/middleware.rb +247 -0
  35. data/lib/findbug/configuration.rb +381 -0
  36. data/lib/findbug/engine.rb +109 -0
  37. data/lib/findbug/performance/instrumentation.rb +336 -0
  38. data/lib/findbug/performance/transaction.rb +193 -0
  39. data/lib/findbug/processing/data_scrubber.rb +163 -0
  40. data/lib/findbug/rails/controller_methods.rb +152 -0
  41. data/lib/findbug/railtie.rb +222 -0
  42. data/lib/findbug/storage/circuit_breaker.rb +223 -0
  43. data/lib/findbug/storage/connection_pool.rb +134 -0
  44. data/lib/findbug/storage/redis_buffer.rb +285 -0
  45. data/lib/findbug/tasks/findbug.rake +167 -0
  46. data/lib/findbug/version.rb +5 -0
  47. data/lib/findbug.rb +216 -0
  48. data/lib/generators/findbug/install_generator.rb +67 -0
  49. data/lib/generators/findbug/templates/POST_INSTALL +41 -0
  50. data/lib/generators/findbug/templates/create_findbug_error_events.rb +44 -0
  51. data/lib/generators/findbug/templates/create_findbug_performance_events.rb +47 -0
  52. data/lib/generators/findbug/templates/initializer.rb +157 -0
  53. data/sig/findbug.rbs +4 -0
  54. metadata +251 -0
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "context"
4
+ require_relative "../storage/redis_buffer"
5
+ require "digest"
6
+ require "socket"
7
+
8
+ module Findbug
9
+ module Capture
10
+ # Middleware captures uncaught exceptions at the Rack level.
11
+ #
12
+ # WHY MIDDLEWARE + SUBSCRIBER?
13
+ # ============================
14
+ #
15
+ # You might wonder: "We already have ExceptionSubscriber. Why middleware?"
16
+ #
17
+ # The subscriber catches errors reported via Rails.error, but:
18
+ # 1. Not all Rails errors go through Rails.error
19
+ # 2. Errors in middleware (before Rails) don't hit the subscriber
20
+ # 3. Some gems raise directly without using Rails.error
21
+ #
22
+ # The middleware is a safety net that catches EVERYTHING at the Rack level.
23
+ #
24
+ # MIDDLEWARE ORDER
25
+ # ================
26
+ #
27
+ # We're inserted AFTER ActionDispatch::ShowExceptions:
28
+ #
29
+ # [Rack Stack]
30
+ # ...
31
+ # ActionDispatch::ShowExceptions ← Converts exceptions to 500 pages
32
+ # Findbug::Capture::Middleware ← WE ARE HERE
33
+ # ...
34
+ # YourController
35
+ #
36
+ # When an exception bubbles up:
37
+ # 1. Controller raises
38
+ # 2. WE catch it first, capture it, then re-raise
39
+ # 3. ShowExceptions catches it and renders 500 page
40
+ #
41
+ # We capture and RE-RAISE so Rails can still do its thing.
42
+ #
43
+ class Middleware
44
+ def initialize(app)
45
+ @app = app
46
+ end
47
+
48
+ def call(env)
49
+ # Skip if Findbug is disabled
50
+ return @app.call(env) unless Findbug.enabled?
51
+
52
+ # Set up request context
53
+ setup_context(env)
54
+
55
+ # Call the next middleware/app
56
+ response = @app.call(env)
57
+
58
+ # Capture any error that was stored in the environment
59
+ # (Some Rails error handlers store the error but don't re-raise)
60
+ capture_env_exception(env)
61
+
62
+ response
63
+ rescue Exception => e # rubocop:disable Lint/RescueException
64
+ # Capture the exception
65
+ capture_exception(e, env)
66
+
67
+ # Re-raise so Rails can handle it (show 500 page, etc.)
68
+ raise
69
+ ensure
70
+ # Always clean up context
71
+ Context.clear!
72
+ end
73
+
74
+ private
75
+
76
+ # Set up context from the Rack request
77
+ #
78
+ # WHY SET UP CONTEXT HERE?
79
+ # ------------------------
80
+ # The middleware runs BEFORE controllers. By setting up context here,
81
+ # all request data is available even if the error occurs early.
82
+ #
83
+ def setup_context(env)
84
+ # Only set up if not already set (avoid overwriting controller-set context)
85
+ return if Context.request.present?
86
+
87
+ rack_request = Rack::Request.new(env)
88
+
89
+ # Skip non-HTTP paths (assets, etc.)
90
+ return unless should_capture_path?(rack_request.path)
91
+
92
+ Context.set_request(Context.from_rack_request(rack_request))
93
+
94
+ # Add automatic breadcrumb for the request
95
+ Context.add_breadcrumb(
96
+ message: "HTTP Request",
97
+ category: "http",
98
+ data: {
99
+ method: rack_request.request_method,
100
+ path: rack_request.path
101
+ }
102
+ )
103
+ end
104
+
105
+ # Capture an exception
106
+ def capture_exception(exception, env)
107
+ return unless should_capture_exception?(exception)
108
+
109
+ # Check if this exception was already captured by the subscriber
110
+ # (to avoid duplicates)
111
+ return if already_captured?(env, exception)
112
+
113
+ # Mark as captured
114
+ mark_captured(env, exception)
115
+
116
+ # Build event data
117
+ event_data = build_event_data(exception, env)
118
+
119
+ # Push to Redis (async)
120
+ Storage::RedisBuffer.push_error(event_data)
121
+ rescue StandardError => e
122
+ # NEVER let Findbug crash your app
123
+ Findbug.logger.error("[Findbug] Middleware capture failed: #{e.message}")
124
+ end
125
+
126
+ # Capture exceptions stored in env (by error handlers)
127
+ def capture_env_exception(env)
128
+ # ActionDispatch::ShowExceptions stores the exception in env
129
+ exception = env["action_dispatch.exception"]
130
+ return unless exception
131
+
132
+ capture_exception(exception, env)
133
+ end
134
+
135
+ def should_capture_exception?(exception)
136
+ return false unless Findbug.config.should_capture_exception?(exception)
137
+
138
+ # Skip exceptions that indicate normal HTTP flows
139
+ # These are "expected" and don't need tracking
140
+ exception_class = exception.class.name
141
+
142
+ expected_exceptions = %w[
143
+ ActionController::RoutingError
144
+ ActionController::UnknownFormat
145
+ ActionController::BadRequest
146
+ ]
147
+
148
+ !expected_exceptions.include?(exception_class)
149
+ end
150
+
151
+ def should_capture_path?(path)
152
+ Findbug.config.should_capture_path?(path)
153
+ end
154
+
155
+ # Check if already captured (deduplication)
156
+ def already_captured?(env, exception)
157
+ captured_id = env["findbug.captured_exception_id"]
158
+ return false unless captured_id
159
+
160
+ captured_id == exception.object_id
161
+ end
162
+
163
+ def mark_captured(env, exception)
164
+ env["findbug.captured_exception_id"] = exception.object_id
165
+ end
166
+
167
+ def build_event_data(exception, env)
168
+ {
169
+ # Exception details
170
+ exception_class: exception.class.name,
171
+ message: exception.message,
172
+ backtrace: clean_backtrace(exception.backtrace),
173
+
174
+ # Metadata
175
+ severity: "error",
176
+ handled: false,
177
+ source: "middleware",
178
+
179
+ # Context
180
+ context: Context.to_h,
181
+
182
+ # Fingerprint
183
+ fingerprint: generate_fingerprint(exception),
184
+
185
+ # Timing
186
+ captured_at: Time.now.utc.iso8601(3),
187
+
188
+ # Environment
189
+ environment: Findbug.config.environment,
190
+ release: Findbug.config.release,
191
+ server: server_info
192
+ }
193
+ end
194
+
195
+ def clean_backtrace(backtrace)
196
+ return [] unless backtrace
197
+
198
+ backtrace.first(50).map do |line|
199
+ if defined?(Rails.root) && Rails.root
200
+ line.sub(Rails.root.to_s + "/", "")
201
+ else
202
+ line
203
+ end
204
+ end
205
+ end
206
+
207
+ def generate_fingerprint(exception)
208
+ components = [
209
+ exception.class.name,
210
+ normalize_message(exception.message),
211
+ top_frame(exception.backtrace)
212
+ ]
213
+
214
+ Digest::SHA256.hexdigest(components.join("\n"))
215
+ end
216
+
217
+ def normalize_message(message)
218
+ return "" unless message
219
+
220
+ message
221
+ .gsub(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, "{uuid}")
222
+ .gsub(/\b\d+\.?\d*\b/, "{number}")
223
+ .gsub(/'[^']*'/, "'{string}'")
224
+ .gsub(/"[^"]*"/, '"{string}"')
225
+ end
226
+
227
+ def top_frame(backtrace)
228
+ return "" unless backtrace&.any?
229
+
230
+ app_line = backtrace.find do |line|
231
+ line.include?("/app/") || line.include?("/lib/")
232
+ end
233
+
234
+ (app_line || backtrace.first).to_s
235
+ end
236
+
237
+ def server_info
238
+ {
239
+ hostname: Socket.gethostname,
240
+ pid: Process.pid,
241
+ ruby_version: RUBY_VERSION,
242
+ rails_version: (Rails.version if defined?(Rails))
243
+ }
244
+ end
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,381 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Findbug
4
+ # Configuration holds all settings for Findbug.
5
+ #
6
+ # WHY THIS PATTERN?
7
+ # -----------------
8
+ # This is the standard Ruby gem configuration pattern. Users call:
9
+ #
10
+ # Findbug.configure do |config|
11
+ # config.redis_url = "redis://localhost:6379/1"
12
+ # config.enabled = Rails.env.production?
13
+ # end
14
+ #
15
+ # Benefits:
16
+ # 1. All settings in one place (easy to find/audit)
17
+ # 2. Sensible defaults (works without configuration)
18
+ # 3. Type checking and validation at startup (fail fast)
19
+ # 4. Isolated from global state (each setting is an instance variable)
20
+ #
21
+ class Configuration
22
+ # ----- Core Settings -----
23
+
24
+ # Whether Findbug is enabled. Disable in test environments to avoid noise.
25
+ # Default: true (enabled)
26
+ attr_accessor :enabled
27
+
28
+ # Redis connection URL. We use a SEPARATE Redis connection from your app
29
+ # to avoid any interference with your caching/Sidekiq.
30
+ # Default: redis://localhost:6379/1 (note: database 1, not 0)
31
+ attr_accessor :redis_url
32
+
33
+ # Size of the Redis connection pool. More connections = more concurrent writes.
34
+ # Rule of thumb: match your Puma/Unicorn worker count.
35
+ # Default: 5
36
+ attr_accessor :redis_pool_size
37
+
38
+ # Timeout for getting a connection from the pool (in seconds).
39
+ # If all connections are busy, we wait this long before giving up.
40
+ # Default: 1 second (fast fail to avoid blocking your app)
41
+ attr_accessor :redis_pool_timeout
42
+
43
+ # ----- Error Capture Settings -----
44
+
45
+ # Sample rate for error capture (0.0 to 1.0).
46
+ # 1.0 = capture 100% of errors
47
+ # 0.5 = capture 50% of errors (randomly sampled)
48
+ # Useful for extremely high-traffic apps where you don't need every error.
49
+ # Default: 1.0 (capture everything)
50
+ attr_accessor :sample_rate
51
+
52
+ # Exception classes to ignore. These won't be captured at all.
53
+ # Common ignores: ActiveRecord::RecordNotFound (404s), ActionController::RoutingError
54
+ # Default: empty array
55
+ attr_accessor :ignored_exceptions
56
+
57
+ # Paths to ignore (regex patterns). Useful for health checks, assets, etc.
58
+ # Example: [/^\/health/, /^\/assets/]
59
+ # Default: empty array
60
+ attr_accessor :ignored_paths
61
+
62
+ # ----- Performance Monitoring Settings -----
63
+
64
+ # Whether to enable performance monitoring (request timing, SQL queries).
65
+ # Default: true
66
+ attr_accessor :performance_enabled
67
+
68
+ # Sample rate for performance monitoring (0.0 to 1.0).
69
+ # Performance data is more voluminous than errors, so you might want to sample.
70
+ # Default: 0.1 (10% of requests)
71
+ attr_accessor :performance_sample_rate
72
+
73
+ # Threshold in ms. Only record requests slower than this.
74
+ # Helps reduce noise from fast requests.
75
+ # Default: 0 (record all sampled requests)
76
+ attr_accessor :slow_request_threshold_ms
77
+
78
+ # Threshold in ms for flagging slow SQL queries.
79
+ # Default: 100ms
80
+ attr_accessor :slow_query_threshold_ms
81
+
82
+ # ----- Data Scrubbing (Security) -----
83
+
84
+ # Field names to scrub from captured data. These will be replaced with [FILTERED].
85
+ # CRITICAL for PII/security compliance.
86
+ # Default: common sensitive fields
87
+ attr_accessor :scrub_fields
88
+
89
+ # Whether to scrub request headers.
90
+ # Default: true (scrubs Authorization, Cookie, etc.)
91
+ attr_accessor :scrub_headers
92
+
93
+ # Additional headers to scrub (beyond defaults).
94
+ # Default: empty array
95
+ attr_accessor :scrub_header_names
96
+
97
+ # ----- Storage & Retention -----
98
+
99
+ # How many days to keep error/performance data in the database.
100
+ # Older records are automatically deleted by the cleanup job.
101
+ # Default: 30 days
102
+ attr_accessor :retention_days
103
+
104
+ # Maximum buffer size in Redis (number of events).
105
+ # Prevents Redis memory from growing unbounded if DB persistence falls behind.
106
+ # Default: 10000 events
107
+ attr_accessor :max_buffer_size
108
+
109
+ # Redis key TTL for buffered events (in seconds).
110
+ # Events older than this are automatically expired by Redis.
111
+ # Default: 86400 (24 hours)
112
+ attr_accessor :buffer_ttl
113
+
114
+ # ----- Background Jobs -----
115
+
116
+ # Queue name for Findbug's background jobs.
117
+ # Default: "findbug"
118
+ attr_accessor :queue_name
119
+
120
+ # Batch size for persistence job (how many events to move from Redis to DB at once).
121
+ # Larger = more efficient, but uses more memory.
122
+ # Default: 100
123
+ attr_accessor :persist_batch_size
124
+
125
+ # Interval (in seconds) for the background persister thread.
126
+ # This is how often events are moved from Redis to the database.
127
+ # Default: 30 seconds
128
+ attr_accessor :persist_interval
129
+
130
+ # Whether to use the built-in background persister thread.
131
+ # Set to false if you want to use ActiveJob/Sidekiq instead.
132
+ # Default: true
133
+ attr_accessor :auto_persist
134
+
135
+ # ----- Web Dashboard -----
136
+
137
+ # Username for basic auth on the dashboard.
138
+ # Default: nil (dashboard disabled if not set)
139
+ attr_accessor :web_username
140
+
141
+ # Password for basic auth on the dashboard.
142
+ # Default: nil (dashboard disabled if not set)
143
+ attr_accessor :web_password
144
+
145
+ # Path prefix for the dashboard. The dashboard will be mounted at this path.
146
+ # Default: "/findbug"
147
+ attr_accessor :web_path
148
+
149
+ # ----- Alert Settings -----
150
+
151
+ # Alert configuration object (set via block)
152
+ attr_reader :alerts
153
+
154
+ # ----- Misc -----
155
+
156
+ # Release/version identifier (e.g., git SHA, semantic version).
157
+ # Useful for tracking which deploy introduced a bug.
158
+ # Default: nil (auto-detected from ENV['FINDBUG_RELEASE'] or Git)
159
+ attr_accessor :release
160
+
161
+ # Environment name override.
162
+ # Default: Rails.env
163
+ attr_accessor :environment
164
+
165
+ # Custom logger. If nil, uses Rails.logger.
166
+ # Default: nil
167
+ attr_accessor :logger
168
+
169
+ def initialize
170
+ # Set sensible defaults
171
+ @enabled = true
172
+
173
+ # Redis defaults - note we use database 1 to avoid conflicts
174
+ @redis_url = ENV.fetch("FINDBUG_REDIS_URL", "redis://localhost:6379/1")
175
+ @redis_pool_size = ENV.fetch("FINDBUG_REDIS_POOL_SIZE", 5).to_i
176
+ @redis_pool_timeout = 1
177
+
178
+ # Error capture defaults
179
+ @sample_rate = 1.0
180
+ @ignored_exceptions = []
181
+ @ignored_paths = []
182
+
183
+ # Performance defaults
184
+ @performance_enabled = true
185
+ @performance_sample_rate = 0.1
186
+ @slow_request_threshold_ms = 0
187
+ @slow_query_threshold_ms = 100
188
+
189
+ # Security defaults - these are CRITICAL
190
+ @scrub_fields = %w[
191
+ password password_confirmation
192
+ secret secret_key secret_token
193
+ api_key api_secret
194
+ access_token refresh_token
195
+ credit_card card_number cvv
196
+ ssn social_security
197
+ private_key
198
+ ]
199
+ @scrub_headers = true
200
+ @scrub_header_names = []
201
+
202
+ # Storage defaults
203
+ @retention_days = 30
204
+ @max_buffer_size = 10_000
205
+ @buffer_ttl = 86_400 # 24 hours
206
+
207
+ # Job defaults
208
+ @queue_name = "findbug"
209
+ @persist_batch_size = 100
210
+ @persist_interval = 30
211
+ @auto_persist = true
212
+
213
+ # Web defaults
214
+ @web_username = ENV["FINDBUG_USERNAME"]
215
+ @web_password = ENV["FINDBUG_PASSWORD"]
216
+ @web_path = "/findbug"
217
+
218
+ # Alerts - initialized empty, configured via block
219
+ @alerts = AlertConfiguration.new
220
+
221
+ # Misc
222
+ @release = ENV["FINDBUG_RELEASE"]
223
+ @environment = nil # Will use Rails.env if not set
224
+ @logger = nil # Will use Rails.logger if not set
225
+ end
226
+
227
+ # DSL for configuring alerts
228
+ #
229
+ # Example:
230
+ # config.alerts do |alerts|
231
+ # alerts.email enabled: true, recipients: ["team@example.com"]
232
+ # alerts.slack enabled: true, webhook_url: ENV["SLACK_WEBHOOK"]
233
+ # end
234
+ #
235
+ def alerts
236
+ if block_given?
237
+ yield @alerts
238
+ else
239
+ @alerts
240
+ end
241
+ end
242
+
243
+ # Validate configuration at startup
244
+ # Raises ConfigurationError if something is wrong
245
+ def validate!
246
+ validate_sample_rates!
247
+ validate_redis!
248
+ validate_web_auth!
249
+ end
250
+
251
+ # Check if the dashboard should be enabled
252
+ def web_enabled?
253
+ web_username.present? && web_password.present?
254
+ end
255
+
256
+ # Check if we should capture this exception class
257
+ def should_capture_exception?(exception)
258
+ return false unless enabled
259
+ return false if ignored_exceptions.any? { |klass| exception.is_a?(klass) }
260
+
261
+ # Apply sampling
262
+ rand <= sample_rate
263
+ end
264
+
265
+ # Check if we should capture this request path
266
+ def should_capture_path?(path)
267
+ return false unless enabled
268
+ return false if ignored_paths.any? { |pattern| path.match?(pattern) }
269
+
270
+ true
271
+ end
272
+
273
+ # Check if we should capture performance for this request
274
+ def should_capture_performance?
275
+ return false unless enabled
276
+ return false unless performance_enabled
277
+
278
+ # Apply sampling
279
+ rand <= performance_sample_rate
280
+ end
281
+
282
+ private
283
+
284
+ def validate_sample_rates!
285
+ unless sample_rate.between?(0.0, 1.0)
286
+ raise ConfigurationError, "sample_rate must be between 0.0 and 1.0"
287
+ end
288
+
289
+ unless performance_sample_rate.between?(0.0, 1.0)
290
+ raise ConfigurationError, "performance_sample_rate must be between 0.0 and 1.0"
291
+ end
292
+ end
293
+
294
+ def validate_redis!
295
+ return unless enabled
296
+
297
+ unless redis_url.present?
298
+ raise ConfigurationError, "redis_url is required when Findbug is enabled"
299
+ end
300
+ end
301
+
302
+ def validate_web_auth!
303
+ # If one is set, both must be set
304
+ if (web_username.present? && web_password.blank?) ||
305
+ (web_username.blank? && web_password.present?)
306
+ raise ConfigurationError, "Both web_username and web_password must be set for dashboard authentication"
307
+ end
308
+ end
309
+ end
310
+
311
+ # Nested class for alert configuration
312
+ #
313
+ # WHY A SEPARATE CLASS?
314
+ # Alerts have their own sub-configuration (multiple channels, each with settings).
315
+ # Nesting keeps the main Configuration cleaner.
316
+ #
317
+ class AlertConfiguration
318
+ attr_accessor :throttle_period
319
+
320
+ def initialize
321
+ @channels = {}
322
+ @throttle_period = 300 # 5 minutes default
323
+ end
324
+
325
+ # Configure email alerts
326
+ def email(enabled:, recipients: [], **options)
327
+ @channels[:email] = {
328
+ enabled: enabled,
329
+ recipients: Array(recipients),
330
+ **options
331
+ }
332
+ end
333
+
334
+ # Configure Slack alerts
335
+ def slack(enabled:, webhook_url: nil, channel: nil, **options)
336
+ @channels[:slack] = {
337
+ enabled: enabled,
338
+ webhook_url: webhook_url,
339
+ channel: channel,
340
+ **options
341
+ }
342
+ end
343
+
344
+ # Configure Discord alerts
345
+ def discord(enabled:, webhook_url: nil, **options)
346
+ @channels[:discord] = {
347
+ enabled: enabled,
348
+ webhook_url: webhook_url,
349
+ **options
350
+ }
351
+ end
352
+
353
+ # Configure generic webhook alerts
354
+ def webhook(enabled:, url: nil, headers: {}, **options)
355
+ @channels[:webhook] = {
356
+ enabled: enabled,
357
+ url: url,
358
+ headers: headers,
359
+ **options
360
+ }
361
+ end
362
+
363
+ # Get configuration for a specific channel
364
+ def channel(name)
365
+ @channels[name.to_sym]
366
+ end
367
+
368
+ # Get all enabled channels
369
+ def enabled_channels
370
+ @channels.select { |_, config| config[:enabled] }
371
+ end
372
+
373
+ # Check if any alerts are configured
374
+ def any_enabled?
375
+ enabled_channels.any?
376
+ end
377
+ end
378
+
379
+ # Custom error for configuration issues
380
+ class ConfigurationError < StandardError; end
381
+ end