Skip to content

Commit dd84c7a

Browse files
CopilotGrantBirki
andcommitted
Implement pluggable instrument system and comprehensive documentation
Co-authored-by: GrantBirki <[email protected]>
1 parent 6fee9c5 commit dd84c7a

20 files changed

+1381
-26
lines changed

docs/instrument_plugins.md

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

0 commit comments

Comments
 (0)