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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 064c67e6e15924cf8a33314dfc3b4f174e749a5eef347b3e7620afb3785de58a
4
- data.tar.gz: 9fe2be17be0fb8c3d171b302c0552b90f73277fea962f324de14ed84b0d5c487
3
+ metadata.gz: eeb40f85518ab7f9d4d43e33bb9605206de73f6e3d8fc24979039c39c2c11c02
4
+ data.tar.gz: e075b4c269916b53dfecd103d7a10ec9ca0b6ce99de3b82c37b391fc386d56cf
5
5
  SHA512:
6
- metadata.gz: d75e1e6e3fc2b6b743414c7fb83ee0beb92ee54f41dd9cc7cd16e87d8036c545d5484e66930b3b50a63953d232c79c4043c6281b1c701d133ed4606b5c44c0ac
7
- data.tar.gz: c29d4e4a013d41504193009688520ae0afdc55f9d3d6a4281c57d0e203f083314f919c7385bd4ae59de2f56055540ad98d5cc45d1f4fd009103836a6b871fe6d
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 delivers structured JSON payloads to configurable webhook endpoints.
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
- This creates:
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
- Edit `config/initializers/oopsie_exceptions.rb`:
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
- config.add_webhook "https://your-endpoint.com/webhooks/exceptions",
32
- headers: { "Authorization" => "Bearer #{ENV['OOPSIE_WEBHOOK_TOKEN']}" }
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.enabled = Rails.env.production?
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
- ## What happens automatically
67
+ ### Configuration options
41
68
 
42
- - **Rack middleware** inserted after `DebugExceptions` catches unhandled exceptions
43
- - **Rails.error subscriber** captures framework-reported errors
44
- - Request context (URL, IP, params, headers) is collected automatically
45
- - Webhooks are delivered async via ActiveJob
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
- ## Adding user context
85
+ ## What gets captured automatically
48
86
 
49
- In your `ApplicationController`:
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
- before_action :set_oopsie_context
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
- OopsieExceptions.set_context(
58
- user: current_user ? { id: current_user.id, email: current_user.email } : nil,
59
- action: "#{self.class.name}##{action_name}"
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 payload with:
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 info and custom data you set
152
+ - **context** — `user`, `action`, `job`, plus anything you set via `context_builder` / `set_context`
91
153
  - **server** — hostname, PID, Ruby/Rails versions
92
- - **app** — name, environment
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
- ## Filtering
160
+ By default these are dropped:
96
161
 
97
- By default, common noise exceptions are ignored (404s, routing errors, `ActionController::BadRequest`, etc.). Sensitive parameters (`password`, `token`, `secret`, `api_key`) and headers (`Authorization`, `Cookie`) are automatically filtered from payloads.
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
- Customize in your initializer:
172
+ Add your own:
100
173
 
101
174
  ```ruby
102
- config.ignored_exceptions += ["MyApp::IgnorableError"]
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 and delivery job"
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 create_delivery_job
15
- template "delivery_job.rb", "app/jobs/oopsie_exceptions/delivery_job.rb"
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 steps:"
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
- # Add webhook endpoints — exceptions get POSTed here as JSON
3
- # config.add_webhook "https://your-endpoint.com/webhooks/exceptions",
4
- # headers: { "Authorization" => "Bearer #{ENV['OOPSIE_WEBHOOK_TOKEN']}" },
5
- # name: "primary"
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
- # Set false to disable in dev/test
23
- config.enabled = Rails.env.production? || Rails.env.staging?
31
+ # Master kill switch leave off in test
32
+ config.enabled = Rails.env.development? || Rails.env.production?
24
33
 
25
- # Optional: modify or drop payloads before sending
26
- # config.before_notify = ->(payload) {
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
- # Add custom context to every exception from the Rack env.
32
- # This runs on every requestuse it to attach the current user, feature flags, etc.
37
+ # Attach the current user and controller#action to every exception.
38
+ # `env` is the Rack envruns 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: env["action_dispatch.request.path_parameters"]&.slice(:controller, :action)&.values&.join("#")
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
- ctx = {
31
- request: {
32
- url: request.url,
33
- method: request.request_method,
34
- ip: request.ip,
35
- user_agent: env["HTTP_USER_AGENT"],
36
- referer: env["HTTP_REFERER"],
37
- request_id: env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"],
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
- if config.capture_request_body && request.content_type&.include?("application/json")
44
- body = request.body.read
45
- request.body.rewind
46
- ctx[:request][:body] = body[0, 10_000] if body && !body.empty?
47
- end
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
- around_perform do |job, block|
12
- OopsieExceptions.with_context(
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OopsieExceptions
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.1"
5
5
  end
@@ -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
@@ -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::DeliveryJob)
62
- DeliveryJob.perform_later(
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.0.0
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: 2026-04-04 00:00:00.000000000 Z
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.5.23
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