Skip to content

Commit 44b1d7e

Browse files
authored
Merge pull request #20 from github/copilot/fix-19
feat: implement global lifecycle hooks and stats/failbot components
2 parents a4f747f + feefdd7 commit 44b1d7e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+2338
-23
lines changed

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ gemspec
66

77
group :development do
88
gem "irb", "~> 1"
9+
gem "rack-test", "~> 2.2"
910
gem "rspec", "~> 3"
1011
gem "rubocop", "~> 1"
1112
gem "rubocop-github", "~> 0.26"

Gemfile.lock

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
PATH
22
remote: .
33
specs:
4-
hooks-ruby (0.0.2)
4+
hooks-ruby (0.0.3)
55
dry-schema (~> 1.14, >= 1.14.1)
66
grape (~> 2.3)
7-
grape-swagger (~> 2.1, >= 2.1.2)
87
puma (~> 6.6)
98
redacting-logger (~> 1.5)
109
retryable (~> 3.0, >= 3.0.5)
@@ -76,9 +75,6 @@ GEM
7675
mustermann-grape (~> 1.1.0)
7776
rack (>= 2)
7877
zeitwerk
79-
grape-swagger (2.1.2)
80-
grape (>= 1.7, < 3.0)
81-
rack-test (~> 2)
8278
hashdiff (1.2.0)
8379
i18n (1.14.7)
8480
concurrent-ruby (~> 1.0)
@@ -225,6 +221,7 @@ PLATFORMS
225221
DEPENDENCIES
226222
hooks-ruby!
227223
irb (~> 1)
224+
rack-test (~> 2.2)
228225
rspec (~> 3)
229226
rubocop (~> 1)
230227
rubocop-github (~> 0.26)

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,14 @@ See the [Auth Plugins](docs/auth_plugins.md) documentation for even more informa
294294

295295
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.
296296

297+
### Lifecycle Plugins
298+
299+
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.
300+
301+
### Instrument Plugins
302+
303+
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.
304+
297305
## Contributing 🤝
298306

299307
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.

docs/instrument_plugins.md

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
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

Comments
 (0)