oopsie_exceptions 1.0.0 → 1.1.1
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.
- checksums.yaml +4 -4
- data/README.md +143 -32
- data/lib/generators/oopsie_exceptions/install_generator.rb +10 -15
- data/lib/generators/oopsie_exceptions/templates/initializer.rb +34 -21
- data/lib/oopsie_exceptions/active_job_extension.rb +35 -0
- data/lib/oopsie_exceptions/context.rb +52 -21
- data/lib/oopsie_exceptions/railtie.rb +2 -15
- data/lib/oopsie_exceptions/version.rb +1 -1
- data/lib/oopsie_exceptions/webhook_job.rb +29 -0
- data/lib/oopsie_exceptions.rb +3 -2
- metadata +19 -8
- data/lib/generators/oopsie_exceptions/templates/delivery_job.rb +0 -21
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eeb40f85518ab7f9d4d43e33bb9605206de73f6e3d8fc24979039c39c2c11c02
|
|
4
|
+
data.tar.gz: e075b4c269916b53dfecd103d7a10ec9ca0b6ce99de3b82c37b391fc386d56cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 71d6c2ce97bae33d43f3d0ad1fee09ede56ca47576c11303a98e663e2046f6719fbd309d1429f6ce375956cff33fefa404a61ddbacf51c82df377b5296929820
|
|
7
|
+
data.tar.gz: 064032a2ec6f0d414bcb24935932a2775a8dbe691ff0b6a1252681bf798b68e347f9e5de8d14fa897d29bca8d22caca262751723343730c62c98eb6c99e15d5d
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Lightweight exception capture and webhook delivery for Rails. Like Sentry/Rollbar, but self-hosted and webhook-driven.
|
|
4
4
|
|
|
5
|
-
Captures unhandled exceptions from web requests and background jobs, enriches them with request/user/server context, and
|
|
5
|
+
Captures unhandled exceptions from web requests and background jobs, enriches them with request/user/server context, and POSTs structured JSON payloads to one or more webhook endpoints.
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
@@ -18,49 +18,101 @@ Then run the install generator:
|
|
|
18
18
|
bin/rails generate oopsie_exceptions:install
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
- `config/initializers/oopsie_exceptions.rb` — configuration
|
|
23
|
-
- `app/jobs/oopsie_exceptions/delivery_job.rb` — async webhook delivery
|
|
21
|
+
That creates a single file: `config/initializers/oopsie_exceptions.rb`. No code lands in `app/` — the Rack middleware, Rails error subscriber, ActiveJob hook, and webhook delivery job all live inside the gem.
|
|
24
22
|
|
|
25
23
|
## Configuration
|
|
26
24
|
|
|
27
|
-
|
|
25
|
+
The most common pattern is to configure a different webhook per environment, with the URL and auth token coming from environment variables. Here's a real-world initializer:
|
|
28
26
|
|
|
29
27
|
```ruby
|
|
28
|
+
# config/initializers/oopsie_exceptions.rb
|
|
30
29
|
OopsieExceptions.configure do |config|
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
if Rails.env.development?
|
|
31
|
+
config.add_webhook(
|
|
32
|
+
"http://localhost:3099/api/v1/exceptions",
|
|
33
|
+
headers: { "Authorization" => "Bearer #{ENV['OOPSIE_DEV_TOKEN']}" },
|
|
34
|
+
name: "oopsie-local"
|
|
35
|
+
)
|
|
36
|
+
config.async_delivery = false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if Rails.env.production?
|
|
40
|
+
config.add_webhook(
|
|
41
|
+
"https://oopsie.example.com/api/v1/exceptions",
|
|
42
|
+
headers: { "Authorization" => "Bearer #{ENV['OOPSIE_PROD_TOKEN']}" },
|
|
43
|
+
name: "oopsie-prod"
|
|
44
|
+
)
|
|
45
|
+
config.async_delivery = true
|
|
46
|
+
end
|
|
33
47
|
|
|
34
48
|
config.app_name = "MyApp"
|
|
35
49
|
config.environment = Rails.env
|
|
36
|
-
config.
|
|
50
|
+
config.filter_parameters = %w[password password_confirmation secret token api_key]
|
|
51
|
+
config.enabled = Rails.env.development? || Rails.env.production?
|
|
52
|
+
|
|
53
|
+
# Attach the current user and controller#action to every exception.
|
|
54
|
+
# `env` is the Rack env — runs once per request.
|
|
55
|
+
config.context_builder = ->(env) {
|
|
56
|
+
warden = env["warden"]
|
|
57
|
+
user = warden&.user
|
|
58
|
+
params = env["action_dispatch.request.path_parameters"]
|
|
59
|
+
{
|
|
60
|
+
user: user ? { id: user.id, email: user.email } : nil,
|
|
61
|
+
action: params ? "#{params[:controller]}##{params[:action]}" : nil
|
|
62
|
+
}.compact
|
|
63
|
+
}
|
|
37
64
|
end
|
|
38
65
|
```
|
|
39
66
|
|
|
40
|
-
|
|
67
|
+
### Configuration options
|
|
41
68
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
69
|
+
| Option | Default | Description |
|
|
70
|
+
| --- | --- | --- |
|
|
71
|
+
| `add_webhook(url, headers:, name:)` | — | Register a webhook. Call multiple times for fan-out. |
|
|
72
|
+
| `app_name` | Rails app module name | Identifier sent in every payload. |
|
|
73
|
+
| `environment` | `Rails.env` | Environment label sent in every payload. |
|
|
74
|
+
| `enabled` | `true` | Master kill switch. Set false in test/dev. |
|
|
75
|
+
| `async_delivery` | `true` | Deliver via ActiveJob. Set false to POST inline. |
|
|
76
|
+
| `filter_parameters` | `password, secret, token, api_key, ...` | Param names to redact in payloads. |
|
|
77
|
+
| `filter_headers` | `Authorization, Cookie, Set-Cookie` | Headers to strip from payloads. |
|
|
78
|
+
| `capture_request_body` | `false` | Include first 10KB of JSON request bodies. |
|
|
79
|
+
| `ignored_exceptions` | 404s, routing errors, etc. | Exception class names to silently drop. |
|
|
80
|
+
| `ignore_exception(*names)` | — | Append to `ignored_exceptions`. |
|
|
81
|
+
| `context_builder` | `nil` | `->(env) { Hash }` — extra context per request. |
|
|
82
|
+
| `before_notify` | `nil` | `->(payload) { payload }` — mutate or drop payloads. |
|
|
83
|
+
| `timeout` / `open_timeout` | `10` / `5` | HTTP timeouts (seconds). |
|
|
46
84
|
|
|
47
|
-
##
|
|
85
|
+
## What gets captured automatically
|
|
48
86
|
|
|
49
|
-
|
|
87
|
+
- **Web requests** — A Rack middleware inserted after `ActionDispatch::DebugExceptions` catches unhandled exceptions and 5xx responses.
|
|
88
|
+
- **Background jobs** — `ActiveJob::Base.execute` is wrapped at the class level (the same approach Appsignal uses), so every queue adapter (Solid Queue, Sidekiq, Async, etc.) is covered. Catches `DeserializationError` and missing job classes too.
|
|
89
|
+
- **`Rails.error` reports** — Subscribed via `Rails.error.subscribe`, so anything reported through the framework error reporter (`Rails.error.report` / `Rails.error.handle`) flows through.
|
|
90
|
+
|
|
91
|
+
Each captured exception is enriched with request URL/method/IP/params/headers, the current user (via `context_builder` or `set_context`), server hostname/PID/Ruby version, and a UTC timestamp.
|
|
92
|
+
|
|
93
|
+
Request context capture is best-effort. If Rack rejects a malformed or truncated request body while OopsieExceptions is collecting params, the exception report is still allowed to continue with `request.params` omitted. Payloads include omission metadata such as `params_omitted` / `params_error_class` or `body_omitted` / `body_error_class` when request enrichment fails.
|
|
94
|
+
|
|
95
|
+
## Adding context per-request
|
|
96
|
+
|
|
97
|
+
If you don't want to use `context_builder`, you can set context from a controller:
|
|
50
98
|
|
|
51
99
|
```ruby
|
|
52
|
-
|
|
100
|
+
class ApplicationController < ActionController::Base
|
|
101
|
+
before_action :set_oopsie_context
|
|
53
102
|
|
|
54
|
-
private
|
|
103
|
+
private
|
|
55
104
|
|
|
56
|
-
def set_oopsie_context
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
105
|
+
def set_oopsie_context
|
|
106
|
+
OopsieExceptions.set_context(
|
|
107
|
+
user: current_user ? { id: current_user.id, email: current_user.email } : nil,
|
|
108
|
+
action: "#{self.class.name}##{action_name}"
|
|
109
|
+
)
|
|
110
|
+
end
|
|
61
111
|
end
|
|
62
112
|
```
|
|
63
113
|
|
|
114
|
+
`context_builder` is preferred — it runs in the middleware before the request hits any controller, so it captures context even on errors raised before `before_action` runs.
|
|
115
|
+
|
|
64
116
|
## Manual reporting
|
|
65
117
|
|
|
66
118
|
```ruby
|
|
@@ -71,38 +123,97 @@ rescue => e
|
|
|
71
123
|
end
|
|
72
124
|
```
|
|
73
125
|
|
|
126
|
+
Or scope context to a block:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
OopsieExceptions.with_context(tenant_id: 42) do
|
|
130
|
+
do_work
|
|
131
|
+
end
|
|
132
|
+
```
|
|
133
|
+
|
|
74
134
|
## Multiple webhooks
|
|
75
135
|
|
|
76
136
|
```ruby
|
|
77
|
-
config.add_webhook "https://your-api.com/exceptions"
|
|
137
|
+
config.add_webhook "https://your-api.com/exceptions",
|
|
138
|
+
headers: { "Authorization" => "Bearer #{ENV['PRIMARY_TOKEN']}" }
|
|
139
|
+
|
|
78
140
|
config.add_webhook "https://hooks.slack.com/services/..."
|
|
79
141
|
config.add_webhook "https://discord.com/api/webhooks/..."
|
|
80
142
|
```
|
|
81
143
|
|
|
82
|
-
Each endpoint receives every exception.
|
|
144
|
+
Each endpoint receives every exception. Failures on one webhook don't affect the others.
|
|
83
145
|
|
|
84
146
|
## Payload format
|
|
85
147
|
|
|
86
|
-
Each webhook receives a JSON
|
|
148
|
+
Each webhook receives a JSON POST with:
|
|
87
149
|
|
|
88
150
|
- **exception** — class, message, backtrace, cause chain
|
|
89
151
|
- **request** — URL, method, IP, params, headers, user agent
|
|
90
|
-
- **context** — user
|
|
152
|
+
- **context** — `user`, `action`, `job`, plus anything you set via `context_builder` / `set_context`
|
|
91
153
|
- **server** — hostname, PID, Ruby/Rails versions
|
|
92
|
-
- **app** —
|
|
154
|
+
- **app** — `app_name`, `environment`
|
|
93
155
|
- **timestamp** — UTC ISO8601
|
|
156
|
+
- **handled** — `true` for `OopsieExceptions.report(..., handled: true)`, `false` for unhandled
|
|
157
|
+
|
|
158
|
+
## Filtering noise
|
|
94
159
|
|
|
95
|
-
|
|
160
|
+
By default these are dropped:
|
|
96
161
|
|
|
97
|
-
|
|
162
|
+
```
|
|
163
|
+
ActionController::RoutingError
|
|
164
|
+
ActionController::UnknownFormat
|
|
165
|
+
ActionController::BadRequest
|
|
166
|
+
ActionDispatch::Http::MimeNegotiation::InvalidType
|
|
167
|
+
AbstractController::ActionNotFound
|
|
168
|
+
ActiveRecord::RecordNotFound
|
|
169
|
+
ActionController::UnknownHttpMethod
|
|
170
|
+
```
|
|
98
171
|
|
|
99
|
-
|
|
172
|
+
Add your own:
|
|
100
173
|
|
|
101
174
|
```ruby
|
|
102
|
-
config.
|
|
103
|
-
config.filter_parameters += [:credit_card]
|
|
175
|
+
config.ignore_exception "MyApp::IgnorableError", "ThirdParty::Timeout"
|
|
104
176
|
```
|
|
105
177
|
|
|
178
|
+
Sensitive params (`password`, `token`, `secret`, `api_key`) and headers (`Authorization`, `Cookie`, `Set-Cookie`) are redacted from payloads automatically.
|
|
179
|
+
|
|
180
|
+
## Mutating or dropping payloads
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
config.before_notify = ->(payload) {
|
|
184
|
+
payload[:context][:deploy_sha] = ENV["GIT_SHA"]
|
|
185
|
+
return nil if payload[:exception][:message].include?("known noise")
|
|
186
|
+
payload
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Return `nil` to drop the notification entirely.
|
|
191
|
+
|
|
192
|
+
## Upgrading from earlier versions
|
|
193
|
+
|
|
194
|
+
### Malformed request body handling
|
|
195
|
+
|
|
196
|
+
Apps that added a local guard around OopsieExceptions request context collection for malformed multipart or truncated request bodies can remove that workaround after upgrading to a gem release that includes best-effort request params/body capture. Until the fixed gem version is deployed in the app, keep the app-local guard in place.
|
|
197
|
+
|
|
198
|
+
This gem change only prevents OopsieExceptions from turning context enrichment into a new request failure. Production exception groups for the affected app should still be resolved from that app's deploy verification, not from the gem release alone.
|
|
199
|
+
|
|
200
|
+
### Legacy delivery job cleanup
|
|
201
|
+
|
|
202
|
+
If you're coming from an older version of the gem that generated `app/jobs/oopsie_exceptions/delivery_job.rb` in your app, **delete that file**. The gem now ships its own `OopsieExceptions::WebhookJob` and the host-app file is obsolete.
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
rm app/jobs/oopsie_exceptions/delivery_job.rb
|
|
206
|
+
rmdir app/jobs/oopsie_exceptions # if empty
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Or just re-run the install generator and it will detect and remove the legacy file for you:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
bin/rails generate oopsie_exceptions:install
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
No initializer changes are required.
|
|
216
|
+
|
|
106
217
|
## License
|
|
107
218
|
|
|
108
219
|
MIT — see [LICENSE.txt](LICENSE.txt).
|
|
@@ -5,32 +5,27 @@ module OopsieExceptions
|
|
|
5
5
|
class InstallGenerator < Rails::Generators::Base
|
|
6
6
|
source_root File.expand_path("templates", __dir__)
|
|
7
7
|
|
|
8
|
-
desc "Creates an OopsieExceptions initializer
|
|
8
|
+
desc "Creates an OopsieExceptions initializer"
|
|
9
9
|
|
|
10
10
|
def create_initializer
|
|
11
11
|
template "initializer.rb", "config/initializers/oopsie_exceptions.rb"
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def
|
|
15
|
-
|
|
14
|
+
def remove_legacy_delivery_job
|
|
15
|
+
legacy_path = "app/jobs/oopsie_exceptions/delivery_job.rb"
|
|
16
|
+
return unless File.exist?(File.join(destination_root, legacy_path))
|
|
17
|
+
|
|
18
|
+
say ""
|
|
19
|
+
say "Found legacy #{legacy_path} from a previous version.", :yellow
|
|
20
|
+
say "The gem now ships its own webhook job — this file is no longer used."
|
|
21
|
+
remove_file legacy_path
|
|
16
22
|
end
|
|
17
23
|
|
|
18
24
|
def show_post_install
|
|
19
25
|
say ""
|
|
20
26
|
say "OopsieExceptions installed!", :green
|
|
21
27
|
say ""
|
|
22
|
-
say "Next
|
|
23
|
-
say " 1. Edit config/initializers/oopsie_exceptions.rb to add your webhook URLs"
|
|
24
|
-
say " 2. Optionally add user context in ApplicationController:"
|
|
25
|
-
say ""
|
|
26
|
-
say " before_action :set_oopsie_context"
|
|
27
|
-
say ""
|
|
28
|
-
say " def set_oopsie_context"
|
|
29
|
-
say " OopsieExceptions.set_context("
|
|
30
|
-
say " user: current_user ? { id: current_user.id, email: current_user.email } : nil,"
|
|
31
|
-
say " action: \"\#{self.class.name}#\#{action_name}\""
|
|
32
|
-
say " )"
|
|
33
|
-
say " end"
|
|
28
|
+
say "Next step: edit config/initializers/oopsie_exceptions.rb and add your webhook URLs."
|
|
34
29
|
say ""
|
|
35
30
|
end
|
|
36
31
|
end
|
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
OopsieExceptions.configure do |config|
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
# Webhook endpoints — exceptions get POSTed as JSON.
|
|
3
|
+
# Configure per-environment so dev errors don't hit your prod tracker.
|
|
4
|
+
if Rails.env.development?
|
|
5
|
+
config.add_webhook(
|
|
6
|
+
"http://localhost:3099/api/v1/exceptions",
|
|
7
|
+
headers: { "Authorization" => "Bearer #{ENV['OOPSIE_DEV_TOKEN']}" },
|
|
8
|
+
name: "oopsie-local"
|
|
9
|
+
)
|
|
10
|
+
config.async_delivery = false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
if Rails.env.production?
|
|
14
|
+
config.add_webhook(
|
|
15
|
+
"https://oopsie.example.com/api/v1/exceptions",
|
|
16
|
+
headers: { "Authorization" => "Bearer #{ENV['OOPSIE_PROD_TOKEN']}" },
|
|
17
|
+
name: "oopsie-prod"
|
|
18
|
+
)
|
|
19
|
+
config.async_delivery = true
|
|
20
|
+
end
|
|
6
21
|
|
|
7
22
|
# config.add_webhook "https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK"
|
|
8
23
|
# config.add_webhook "https://discord.com/api/webhooks/YOUR/DISCORD/WEBHOOK"
|
|
@@ -10,32 +25,30 @@ OopsieExceptions.configure do |config|
|
|
|
10
25
|
config.app_name = Rails.application.class.module_parent_name
|
|
11
26
|
config.environment = Rails.env
|
|
12
27
|
|
|
13
|
-
# Deliver webhooks async via ActiveJob (set false for sync delivery)
|
|
14
|
-
config.async_delivery = true
|
|
15
|
-
|
|
16
|
-
# Exceptions that won't be reported (404s, bot garbage, etc.)
|
|
17
|
-
# config.ignore_exception "ActionController::RoutingError"
|
|
18
|
-
|
|
19
28
|
# Filter sensitive params from payloads (inherits from Rails by default)
|
|
20
29
|
config.filter_parameters = Rails.application.config.filter_parameters.map(&:to_s)
|
|
21
30
|
|
|
22
|
-
#
|
|
23
|
-
config.enabled = Rails.env.
|
|
31
|
+
# Master kill switch — leave off in test
|
|
32
|
+
config.enabled = Rails.env.development? || Rails.env.production?
|
|
24
33
|
|
|
25
|
-
#
|
|
26
|
-
# config.
|
|
27
|
-
# payload[:context][:deploy_sha] = ENV["GIT_SHA"]
|
|
28
|
-
# payload # return nil to skip this notification
|
|
29
|
-
# }
|
|
34
|
+
# Exceptions that won't be reported (404s, bot garbage, etc.)
|
|
35
|
+
# config.ignore_exception "MyApp::IgnorableError"
|
|
30
36
|
|
|
31
|
-
#
|
|
32
|
-
#
|
|
37
|
+
# Attach the current user and controller#action to every exception.
|
|
38
|
+
# `env` is the Rack env — runs once per request, before any controller code.
|
|
33
39
|
# config.context_builder = ->(env) {
|
|
34
40
|
# warden = env["warden"]
|
|
35
41
|
# user = warden&.user
|
|
42
|
+
# params = env["action_dispatch.request.path_parameters"]
|
|
36
43
|
# {
|
|
37
44
|
# user: user ? { id: user.id, email: user.email } : nil,
|
|
38
|
-
# action:
|
|
39
|
-
# }
|
|
45
|
+
# action: params ? "#{params[:controller]}##{params[:action]}" : nil
|
|
46
|
+
# }.compact
|
|
47
|
+
# }
|
|
48
|
+
|
|
49
|
+
# Optional: modify or drop payloads before sending
|
|
50
|
+
# config.before_notify = ->(payload) {
|
|
51
|
+
# payload[:context][:deploy_sha] = ENV["GIT_SHA"]
|
|
52
|
+
# payload # return nil to skip this notification
|
|
40
53
|
# }
|
|
41
54
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OopsieExceptions
|
|
4
|
+
# Class-level extension for ActiveJob::Base. Wraps `execute(job_data)`, the
|
|
5
|
+
# entry point queue adapters call, so we capture every unhandled exception
|
|
6
|
+
# — including ones that fire before `perform` runs (e.g. DeserializationError,
|
|
7
|
+
# or a missing job class).
|
|
8
|
+
#
|
|
9
|
+
# This mirrors how Appsignal hooks ActiveJob (lib/appsignal/hooks/active_job.rb).
|
|
10
|
+
module ActiveJobExtension
|
|
11
|
+
def execute(job_data)
|
|
12
|
+
job_context = {
|
|
13
|
+
job: {
|
|
14
|
+
class: job_data["job_class"],
|
|
15
|
+
job_id: job_data["job_id"],
|
|
16
|
+
queue: job_data["queue_name"],
|
|
17
|
+
arguments: job_data["arguments"],
|
|
18
|
+
executions: job_data["executions"],
|
|
19
|
+
provider_job_id: job_data["provider_job_id"]
|
|
20
|
+
}.compact
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
OopsieExceptions.with_context(job_context) do
|
|
24
|
+
super
|
|
25
|
+
rescue Exception => exception
|
|
26
|
+
OopsieExceptions.report(
|
|
27
|
+
exception,
|
|
28
|
+
context: { namespace: "background_job" },
|
|
29
|
+
handled: false
|
|
30
|
+
)
|
|
31
|
+
raise
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "rack"
|
|
3
|
+
require "rack/request"
|
|
4
4
|
|
|
5
5
|
module OopsieExceptions
|
|
6
6
|
module Context
|
|
@@ -27,38 +27,63 @@ module OopsieExceptions
|
|
|
27
27
|
request = Rack::Request.new(env)
|
|
28
28
|
config = OopsieExceptions.configuration
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
params: sanitize_params(request.params, config),
|
|
39
|
-
headers: extract_headers(env, config)
|
|
40
|
-
}
|
|
30
|
+
request_context = {
|
|
31
|
+
url: request.url,
|
|
32
|
+
method: request.request_method,
|
|
33
|
+
ip: request.ip,
|
|
34
|
+
user_agent: env["HTTP_USER_AGENT"],
|
|
35
|
+
referer: env["HTTP_REFERER"],
|
|
36
|
+
request_id: env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"],
|
|
37
|
+
headers: extract_headers(env, config)
|
|
41
38
|
}
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
40
|
+
request_context.merge!(request_params_context(request, config))
|
|
41
|
+
request_context.merge!(request_body_context(request, config))
|
|
42
|
+
|
|
43
|
+
ctx = {
|
|
44
|
+
request: request_context
|
|
45
|
+
}
|
|
48
46
|
|
|
49
47
|
ctx
|
|
50
48
|
end
|
|
51
49
|
|
|
52
50
|
private
|
|
53
51
|
|
|
52
|
+
def request_params_context(request, config)
|
|
53
|
+
{ params: sanitize_params(request.params, config) }
|
|
54
|
+
rescue StandardError => error
|
|
55
|
+
rewind_body(request.body)
|
|
56
|
+
{
|
|
57
|
+
params: {},
|
|
58
|
+
params_omitted: true,
|
|
59
|
+
params_error_class: error.class.name
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def request_body_context(request, config)
|
|
64
|
+
return {} unless config.capture_request_body
|
|
65
|
+
return {} unless request.content_type&.include?("application/json")
|
|
66
|
+
|
|
67
|
+
body_io = request.body
|
|
68
|
+
body = body_io.read
|
|
69
|
+
return {} if body.nil? || body.empty?
|
|
70
|
+
|
|
71
|
+
{ body: body[0, 10_000] }
|
|
72
|
+
rescue StandardError => error
|
|
73
|
+
{
|
|
74
|
+
body_omitted: true,
|
|
75
|
+
body_error_class: error.class.name
|
|
76
|
+
}
|
|
77
|
+
ensure
|
|
78
|
+
rewind_body(body_io)
|
|
79
|
+
end
|
|
80
|
+
|
|
54
81
|
def sanitize_params(params, config)
|
|
55
82
|
filtered = params.reject { |k, _| k == "controller" || k == "action" }
|
|
56
83
|
filter_keys = config.filter_parameters
|
|
57
84
|
filtered.each_with_object({}) do |(k, v), hash|
|
|
58
|
-
hash[k] = filter_keys.any? { |f| k.to_s.include?(f) } ? "[FILTERED]" : v
|
|
85
|
+
hash[k] = filter_keys.any? { |f| k.to_s.include?(f.to_s) } ? "[FILTERED]" : v
|
|
59
86
|
end
|
|
60
|
-
rescue
|
|
61
|
-
{}
|
|
62
87
|
end
|
|
63
88
|
|
|
64
89
|
def extract_headers(env, config)
|
|
@@ -66,11 +91,17 @@ module OopsieExceptions
|
|
|
66
91
|
env.each do |key, value|
|
|
67
92
|
next unless key.start_with?("HTTP_")
|
|
68
93
|
header_name = key.sub("HTTP_", "").split("_").map(&:capitalize).join("-")
|
|
69
|
-
next if config.filter_headers.any? { |h| h.casecmp(header_name) == 0 }
|
|
94
|
+
next if config.filter_headers.any? { |h| h.to_s.casecmp(header_name) == 0 }
|
|
70
95
|
headers[header_name] = value
|
|
71
96
|
end
|
|
72
97
|
headers
|
|
73
98
|
end
|
|
99
|
+
|
|
100
|
+
def rewind_body(body_io)
|
|
101
|
+
body_io.rewind if body_io&.respond_to?(:rewind)
|
|
102
|
+
rescue StandardError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
74
105
|
end
|
|
75
106
|
end
|
|
76
107
|
end
|
|
@@ -8,21 +8,8 @@ module OopsieExceptions
|
|
|
8
8
|
|
|
9
9
|
initializer "oopsie_exceptions.active_job" do
|
|
10
10
|
ActiveSupport.on_load(:active_job) do
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
job: {
|
|
14
|
-
class: job.class.name,
|
|
15
|
-
job_id: job.job_id,
|
|
16
|
-
queue: job.queue_name,
|
|
17
|
-
arguments: job.arguments.map(&:to_s)
|
|
18
|
-
}
|
|
19
|
-
) do
|
|
20
|
-
block.call
|
|
21
|
-
end
|
|
22
|
-
rescue Exception => e
|
|
23
|
-
OopsieExceptions.report(e, context: { namespace: "background_job" }, handled: false)
|
|
24
|
-
raise
|
|
25
|
-
end
|
|
11
|
+
::ActiveJob::Base.extend(OopsieExceptions::ActiveJobExtension)
|
|
12
|
+
require "oopsie_exceptions/webhook_job"
|
|
26
13
|
end
|
|
27
14
|
end
|
|
28
15
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Webhook delivery job. Loaded from the Railtie inside on_load(:active_job),
|
|
4
|
+
# so it's only defined when the host app has ActiveJob available.
|
|
5
|
+
#
|
|
6
|
+
# Inherits from ActiveJob::Base directly (not ApplicationJob) so the gem has
|
|
7
|
+
# zero hard dependencies on host-app code.
|
|
8
|
+
module OopsieExceptions
|
|
9
|
+
class WebhookJob < ActiveJob::Base
|
|
10
|
+
queue_as :default
|
|
11
|
+
|
|
12
|
+
discard_on StandardError do |_job, error|
|
|
13
|
+
logger = OopsieExceptions.configuration.logger
|
|
14
|
+
logger&.error("[OopsieExceptions] WebhookJob discarded permanently: #{error.message}")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
retry_on Net::OpenTimeout, Net::ReadTimeout, wait: 5.seconds, attempts: 3
|
|
18
|
+
|
|
19
|
+
def perform(payload_json, webhook_url, headers_json)
|
|
20
|
+
webhook = OopsieExceptions::Configuration::Webhook.new(
|
|
21
|
+
url: webhook_url,
|
|
22
|
+
headers: JSON.parse(headers_json),
|
|
23
|
+
name: webhook_url
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
OopsieExceptions::WebhookClient.post(webhook, payload_json)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/oopsie_exceptions.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "oopsie_exceptions/payload"
|
|
|
7
7
|
require_relative "oopsie_exceptions/webhook_client"
|
|
8
8
|
require_relative "oopsie_exceptions/middleware"
|
|
9
9
|
require_relative "oopsie_exceptions/error_subscriber"
|
|
10
|
+
require_relative "oopsie_exceptions/active_job_extension"
|
|
10
11
|
require_relative "oopsie_exceptions/railtie" if defined?(Rails::Railtie)
|
|
11
12
|
|
|
12
13
|
module OopsieExceptions
|
|
@@ -58,8 +59,8 @@ module OopsieExceptions
|
|
|
58
59
|
|
|
59
60
|
def deliver(payload)
|
|
60
61
|
configuration.webhook_urls.each do |webhook|
|
|
61
|
-
if configuration.async_delivery && defined?(OopsieExceptions::
|
|
62
|
-
|
|
62
|
+
if configuration.async_delivery && defined?(OopsieExceptions::WebhookJob)
|
|
63
|
+
WebhookJob.perform_later(
|
|
63
64
|
payload.to_json,
|
|
64
65
|
webhook.url,
|
|
65
66
|
webhook.headers.to_json
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: oopsie_exceptions
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Troy
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: rack
|
|
@@ -24,11 +23,24 @@ dependencies:
|
|
|
24
23
|
- - ">="
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
25
|
version: '2.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.0'
|
|
27
40
|
description: Captures unhandled exceptions from web requests and background jobs,
|
|
28
41
|
enriches them with request/user/server context, and delivers structured JSON payloads
|
|
29
42
|
to configurable webhook endpoints. Works with any Rack-based framework; optional
|
|
30
43
|
Rails integration included.
|
|
31
|
-
email:
|
|
32
44
|
executables: []
|
|
33
45
|
extensions: []
|
|
34
46
|
extra_rdoc_files: []
|
|
@@ -36,9 +48,9 @@ files:
|
|
|
36
48
|
- LICENSE.txt
|
|
37
49
|
- README.md
|
|
38
50
|
- lib/generators/oopsie_exceptions/install_generator.rb
|
|
39
|
-
- lib/generators/oopsie_exceptions/templates/delivery_job.rb
|
|
40
51
|
- lib/generators/oopsie_exceptions/templates/initializer.rb
|
|
41
52
|
- lib/oopsie_exceptions.rb
|
|
53
|
+
- lib/oopsie_exceptions/active_job_extension.rb
|
|
42
54
|
- lib/oopsie_exceptions/configuration.rb
|
|
43
55
|
- lib/oopsie_exceptions/context.rb
|
|
44
56
|
- lib/oopsie_exceptions/error_subscriber.rb
|
|
@@ -47,6 +59,7 @@ files:
|
|
|
47
59
|
- lib/oopsie_exceptions/railtie.rb
|
|
48
60
|
- lib/oopsie_exceptions/version.rb
|
|
49
61
|
- lib/oopsie_exceptions/webhook_client.rb
|
|
62
|
+
- lib/oopsie_exceptions/webhook_job.rb
|
|
50
63
|
homepage: https://github.com/theinventor/oopsie_exceptions
|
|
51
64
|
licenses:
|
|
52
65
|
- MIT
|
|
@@ -54,7 +67,6 @@ metadata:
|
|
|
54
67
|
source_code_uri: https://github.com/theinventor/oopsie_exceptions
|
|
55
68
|
changelog_uri: https://github.com/theinventor/oopsie_exceptions/releases
|
|
56
69
|
rubygems_mfa_required: 'true'
|
|
57
|
-
post_install_message:
|
|
58
70
|
rdoc_options: []
|
|
59
71
|
require_paths:
|
|
60
72
|
- lib
|
|
@@ -69,8 +81,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
69
81
|
- !ruby/object:Gem::Version
|
|
70
82
|
version: '0'
|
|
71
83
|
requirements: []
|
|
72
|
-
rubygems_version: 3.
|
|
73
|
-
signing_key:
|
|
84
|
+
rubygems_version: 3.6.7
|
|
74
85
|
specification_version: 4
|
|
75
86
|
summary: Lightweight exception capture and webhook delivery for Ruby (framework-agnostic)
|
|
76
87
|
test_files: []
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
module OopsieExceptions
|
|
2
|
-
class DeliveryJob < ApplicationJob
|
|
3
|
-
queue_as :default
|
|
4
|
-
|
|
5
|
-
discard_on StandardError do |job, error|
|
|
6
|
-
Rails.logger.error("[OopsieExceptions] DeliveryJob failed permanently: #{error.message}")
|
|
7
|
-
end
|
|
8
|
-
|
|
9
|
-
retry_on Net::OpenTimeout, Net::ReadTimeout, wait: 5.seconds, attempts: 3
|
|
10
|
-
|
|
11
|
-
def perform(payload_json, webhook_url, headers_json)
|
|
12
|
-
webhook = OopsieExceptions::Configuration::Webhook.new(
|
|
13
|
-
url: webhook_url,
|
|
14
|
-
headers: JSON.parse(headers_json),
|
|
15
|
-
name: webhook_url
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
OopsieExceptions::WebhookClient.post(webhook, payload_json)
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|