|
| 1 | +# Instrument Plugins |
| 2 | + |
| 3 | +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. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +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. |
| 8 | + |
| 9 | +The instrument plugins are accessible throughout the entire application: |
| 10 | + |
| 11 | +- In handlers via `stats` and `failbot` methods |
| 12 | +- In auth plugins via `stats` and `failbot` class methods |
| 13 | +- In lifecycle plugins via `stats` and `failbot` methods |
| 14 | + |
| 15 | +## Creating Custom Instruments |
| 16 | + |
| 17 | +To create custom instrument implementations, inherit from the appropriate base class and implement the required methods. |
| 18 | + |
| 19 | +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. |
| 20 | + |
| 21 | +You would then set the following attribute in your `hooks.yml` configuration file to point to these custom instrument plugins: |
| 22 | + |
| 23 | +```yaml |
| 24 | +# hooks.yml |
| 25 | +instruments_plugin_dir: ./plugins/instruments |
| 26 | +``` |
| 27 | +
|
| 28 | +### Custom Stats Implementation |
| 29 | +
|
| 30 | +```ruby |
| 31 | +# plugins/instruments/stats.rb |
| 32 | +class Stats < Hooks::Plugins::Instruments::StatsBase |
| 33 | + def initialize |
| 34 | + # Initialize your metrics client |
| 35 | + @client = MyMetricsService.new( |
| 36 | + api_key: ENV["METRICS_API_KEY"], |
| 37 | + namespace: "webhooks" |
| 38 | + ) |
| 39 | + end |
| 40 | + |
| 41 | + def record(metric_name, value, tags = {}) |
| 42 | + @client.gauge(metric_name, value, tags: tags) |
| 43 | + rescue => e |
| 44 | + log.error("Failed to record metric: #{e.message}") |
| 45 | + end |
| 46 | + |
| 47 | + def increment(metric_name, tags = {}) |
| 48 | + @client.increment(metric_name, tags: tags) |
| 49 | + rescue => e |
| 50 | + log.error("Failed to increment metric: #{e.message}") |
| 51 | + end |
| 52 | + |
| 53 | + def timing(metric_name, duration, tags = {}) |
| 54 | + # Convert to milliseconds if your service expects that |
| 55 | + duration_ms = (duration * 1000).round |
| 56 | + @client.timing(metric_name, duration_ms, tags: tags) |
| 57 | + rescue => e |
| 58 | + log.error("Failed to record timing: #{e.message}") |
| 59 | + end |
| 60 | + |
| 61 | + # Optional: Add custom methods specific to your service |
| 62 | + def histogram(metric_name, value, tags = {}) |
| 63 | + @client.histogram(metric_name, value, tags: tags) |
| 64 | + rescue => e |
| 65 | + log.error("Failed to record histogram: #{e.message}") |
| 66 | + end |
| 67 | +end |
| 68 | +``` |
| 69 | + |
| 70 | +### Custom Failbot Implementation |
| 71 | + |
| 72 | +```ruby |
| 73 | +# plugins/instruments/failbot.rb |
| 74 | +class Failbot < Hooks::Plugins::Instruments::FailbotBase |
| 75 | + def initialize |
| 76 | + # Initialize your error reporting client |
| 77 | + @client = MyErrorService.new( |
| 78 | + api_key: ENV["ERROR_REPORTING_API_KEY"], |
| 79 | + environment: ENV["RAILS_ENV"] || "production" |
| 80 | + ) |
| 81 | + end |
| 82 | + |
| 83 | + def report(error_or_message, context = {}) |
| 84 | + if error_or_message.is_a?(Exception) |
| 85 | + @client.report_exception(error_or_message, context) |
| 86 | + else |
| 87 | + @client.report_message(error_or_message, context) |
| 88 | + end |
| 89 | + rescue => e |
| 90 | + log.error("Failed to report error: #{e.message}") |
| 91 | + end |
| 92 | + |
| 93 | + def critical(error_or_message, context = {}) |
| 94 | + enhanced_context = context.merge(severity: "critical") |
| 95 | + report(error_or_message, enhanced_context) |
| 96 | + end |
| 97 | + |
| 98 | + def warning(message, context = {}) |
| 99 | + enhanced_context = context.merge(severity: "warning") |
| 100 | + @client.report_message(message, enhanced_context) |
| 101 | + rescue => e |
| 102 | + log.error("Failed to report warning: #{e.message}") |
| 103 | + end |
| 104 | + |
| 105 | + # Optional: Add custom methods specific to your service |
| 106 | + def set_user_context(user_id:, email: nil) |
| 107 | + @client.set_user_context(user_id: user_id, email: email) |
| 108 | + rescue => e |
| 109 | + log.error("Failed to set user context: #{e.message}") |
| 110 | + end |
| 111 | + |
| 112 | + def add_breadcrumb(message, category: "webhook", data: {}) |
| 113 | + @client.add_breadcrumb(message, category: category, data: data) |
| 114 | + rescue => e |
| 115 | + log.error("Failed to add breadcrumb: #{e.message}") |
| 116 | + end |
| 117 | +end |
| 118 | +``` |
| 119 | + |
| 120 | +## Configuration |
| 121 | + |
| 122 | +To use custom instrument plugins, specify the `instruments_plugin_dir` in your configuration: |
| 123 | + |
| 124 | +```yaml |
| 125 | +# hooks.yml |
| 126 | +instruments_plugin_dir: ./plugins/instruments |
| 127 | +handler_plugin_dir: ./plugins/handlers |
| 128 | +auth_plugin_dir: ./plugins/auth |
| 129 | +lifecycle_plugin_dir: ./plugins/lifecycle |
| 130 | +``` |
| 131 | +
|
| 132 | +Place your instrument plugin files in the specified directory: |
| 133 | +
|
| 134 | +```text |
| 135 | +plugins/ |
| 136 | +└── instruments/ |
| 137 | + ├── stats.rb |
| 138 | + └── failbot.rb |
| 139 | +``` |
| 140 | + |
| 141 | +## File Naming and Class Detection |
| 142 | + |
| 143 | +The framework automatically detects which type of instrument you're creating based on inheritance: |
| 144 | + |
| 145 | +- Classes inheriting from `StatsBase` become the `stats` instrument |
| 146 | +- Classes inheriting from `FailbotBase` become the `failbot` instrument |
| 147 | + |
| 148 | +File naming follows snake_case to PascalCase conversion: |
| 149 | + |
| 150 | +- `stats.rb` → `stats` |
| 151 | +- `sentry_failbot.rb` → `SentryFailbot` |
| 152 | + |
| 153 | +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. |
| 154 | + |
| 155 | +## Usage in Your Code |
| 156 | + |
| 157 | +Once configured, your custom instruments are available throughout the application: |
| 158 | + |
| 159 | +### In Handlers |
| 160 | + |
| 161 | +```ruby |
| 162 | +class MyHandler < Hooks::Plugins::Handlers::Base |
| 163 | + def call(payload:, headers:, config:) |
| 164 | + # Use your custom stats methods |
| 165 | + stats.increment("handler.calls", { handler: "MyHandler" }) |
| 166 | + |
| 167 | + # Use custom methods if you added them |
| 168 | + stats.histogram("payload.size", payload.to_s.length) if stats.respond_to?(:histogram) |
| 169 | + |
| 170 | + result = stats.measure("handler.processing", { handler: "MyHandler" }) do |
| 171 | + process_webhook(payload, headers, config) |
| 172 | + end |
| 173 | + |
| 174 | + # Use your custom failbot methods |
| 175 | + failbot.add_breadcrumb("Handler completed successfully") if failbot.respond_to?(:add_breadcrumb) |
| 176 | + |
| 177 | + result |
| 178 | + rescue => e |
| 179 | + failbot.report(e, { handler: "MyHandler", event: headers["x-github-event"] }) |
| 180 | + raise |
| 181 | + end |
| 182 | +end |
| 183 | +``` |
| 184 | + |
| 185 | +### In Lifecycle Plugins |
| 186 | + |
| 187 | +```ruby |
| 188 | +class MetricsLifecycle < Hooks::Plugins::Lifecycle |
| 189 | + def on_request(env) |
| 190 | + # Your custom stats implementation will be used |
| 191 | + stats.increment("requests.total", { |
| 192 | + path: env["PATH_INFO"], |
| 193 | + method: env["REQUEST_METHOD"] |
| 194 | + }) |
| 195 | + end |
| 196 | + |
| 197 | + def on_error(exception, env) |
| 198 | + # Your custom failbot implementation will be used |
| 199 | + failbot.report(exception, { |
| 200 | + path: env["PATH_INFO"], |
| 201 | + handler: env["hooks.handler"] |
| 202 | + }) |
| 203 | + end |
| 204 | +end |
| 205 | +``` |
| 206 | + |
| 207 | +## Popular Integrations |
| 208 | + |
| 209 | +### DataDog Stats |
| 210 | + |
| 211 | +```ruby |
| 212 | +class DatadogStats < Hooks::Plugins::Instruments::StatsBase |
| 213 | + def initialize |
| 214 | + require "datadog/statsd" |
| 215 | + @statsd = Datadog::Statsd.new("localhost", 8125, namespace: "webhooks") |
| 216 | + end |
| 217 | + |
| 218 | + def record(metric_name, value, tags = {}) |
| 219 | + @statsd.gauge(metric_name, value, tags: format_tags(tags)) |
| 220 | + end |
| 221 | + |
| 222 | + def increment(metric_name, tags = {}) |
| 223 | + @statsd.increment(metric_name, tags: format_tags(tags)) |
| 224 | + end |
| 225 | + |
| 226 | + def timing(metric_name, duration, tags = {}) |
| 227 | + @statsd.timing(metric_name, duration, tags: format_tags(tags)) |
| 228 | + end |
| 229 | + |
| 230 | + private |
| 231 | + |
| 232 | + def format_tags(tags) |
| 233 | + tags.map { |k, v| "#{k}:#{v}" } |
| 234 | + end |
| 235 | +end |
| 236 | +``` |
| 237 | + |
| 238 | +### Sentry Failbot |
| 239 | + |
| 240 | +```ruby |
| 241 | +class SentryFailbot < Hooks::Plugins::Instruments::FailbotBase |
| 242 | + def initialize |
| 243 | + require "sentry-ruby" |
| 244 | + Sentry.init do |config| |
| 245 | + config.dsn = ENV["SENTRY_DSN"] |
| 246 | + config.environment = ENV["RAILS_ENV"] || "production" |
| 247 | + end |
| 248 | + end |
| 249 | + |
| 250 | + def report(error_or_message, context = {}) |
| 251 | + Sentry.with_scope do |scope| |
| 252 | + context.each { |key, value| scope.set_context(key, value) } |
| 253 | + |
| 254 | + if error_or_message.is_a?(Exception) |
| 255 | + Sentry.capture_exception(error_or_message) |
| 256 | + else |
| 257 | + Sentry.capture_message(error_or_message) |
| 258 | + end |
| 259 | + end |
| 260 | + end |
| 261 | + |
| 262 | + def critical(error_or_message, context = {}) |
| 263 | + Sentry.with_scope do |scope| |
| 264 | + scope.set_level(:fatal) |
| 265 | + context.each { |key, value| scope.set_context(key, value) } |
| 266 | + |
| 267 | + if error_or_message.is_a?(Exception) |
| 268 | + Sentry.capture_exception(error_or_message) |
| 269 | + else |
| 270 | + Sentry.capture_message(error_or_message) |
| 271 | + end |
| 272 | + end |
| 273 | + end |
| 274 | + |
| 275 | + def warning(message, context = {}) |
| 276 | + Sentry.with_scope do |scope| |
| 277 | + scope.set_level(:warning) |
| 278 | + context.each { |key, value| scope.set_context(key, value) } |
| 279 | + Sentry.capture_message(message) |
| 280 | + end |
| 281 | + end |
| 282 | +end |
| 283 | +``` |
| 284 | + |
| 285 | +## Testing Your Instruments |
| 286 | + |
| 287 | +When testing, you may want to use test doubles or capture calls: |
| 288 | + |
| 289 | +```ruby |
| 290 | +# In your test setup |
| 291 | +class TestStats < Hooks::Plugins::Instruments::StatsBase |
| 292 | + attr_reader :recorded_metrics |
| 293 | + |
| 294 | + def initialize |
| 295 | + @recorded_metrics = [] |
| 296 | + end |
| 297 | + |
| 298 | + def record(metric_name, value, tags = {}) |
| 299 | + @recorded_metrics << { type: :record, name: metric_name, value: value, tags: tags } |
| 300 | + end |
| 301 | + |
| 302 | + def increment(metric_name, tags = {}) |
| 303 | + @recorded_metrics << { type: :increment, name: metric_name, tags: tags } |
| 304 | + end |
| 305 | + |
| 306 | + def timing(metric_name, duration, tags = {}) |
| 307 | + @recorded_metrics << { type: :timing, name: metric_name, duration: duration, tags: tags } |
| 308 | + end |
| 309 | +end |
| 310 | + |
| 311 | +# Use in tests |
| 312 | +test_stats = TestStats.new |
| 313 | +Hooks::Core::GlobalComponents.stats = test_stats |
| 314 | + |
| 315 | +# Your test code here |
| 316 | + |
| 317 | +expect(test_stats.recorded_metrics).to include( |
| 318 | + { type: :increment, name: "webhook.processed", tags: { handler: "MyHandler" } } |
| 319 | +) |
| 320 | +``` |
| 321 | + |
| 322 | +## Best Practices |
| 323 | + |
| 324 | +1. **Handle errors gracefully**: Instrument failures should not break webhook processing |
| 325 | +2. **Use appropriate log levels**: Log instrument failures at error level |
| 326 | +3. **Add timeouts**: Network calls to external services should have reasonable timeouts |
| 327 | +4. **Validate configuration**: Check for required environment variables in `initialize` |
| 328 | +5. **Document custom methods**: If you add methods beyond the base interface, document them |
| 329 | +6. **Consider performance**: Instruments are called frequently, so keep operations fast |
| 330 | +7. **Use connection pooling**: For high-throughput scenarios, use connection pooling for external services |
0 commit comments