oopsie_exceptions 0.1.0 → 1.1.0

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: 38724bd4e7e3113063179a4306f5207544e2c8933491ab429433d5294c00f04a
4
- data.tar.gz: c42960e5299dfce450d2cd7992cc3fe5acf1c92ab667c41c555a2c8b90a258a5
3
+ metadata.gz: a3c1bf7176b541c4c19f09ca56724e8c873e1bae149ac9ff9963a4f4fb4f1156
4
+ data.tar.gz: 692fd55d89e3235279b1ba98764f1fbeaeab5e11204c3733674f7a8958956262
5
5
  SHA512:
6
- metadata.gz: aa0c4ed76d142a98fc0b1097ad6956a637296eaa0da494762d839c2b94aecd4eabd061cc5d18e6c22113652ab401e2d74d6767ac86bccaed9c77ea620b990e7e
7
- data.tar.gz: 39de699f9980f8151a55098257c75b50b000300718031baea8b9b8322cd96d8cb49f766a41dfddec615338d3835ece207b0192438f2730146df69d67a8427c48
6
+ metadata.gz: 0c8a3bfd9907819f35ecd979d114e9784daf71ff7c5b2264246782458463c15271a71319208fe5798de1c11178c7bf643e8bcda693124ed74e7f2a50cc25ef17
7
+ data.tar.gz: 9d4f5168d31b516127abc0afa2e894ec02c28cdf076e82f96d555f81c1e38fb3884d3fbca46307572ce2f9749798cf1ace9c4e2fa6d98a6263908c4a695a14a1
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,99 @@ 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
+ ## Adding context per-request
94
+
95
+ If you don't want to use `context_builder`, you can set context from a controller:
50
96
 
51
97
  ```ruby
52
- before_action :set_oopsie_context
98
+ class ApplicationController < ActionController::Base
99
+ before_action :set_oopsie_context
53
100
 
54
- private
101
+ private
55
102
 
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
- )
103
+ def set_oopsie_context
104
+ OopsieExceptions.set_context(
105
+ user: current_user ? { id: current_user.id, email: current_user.email } : nil,
106
+ action: "#{self.class.name}##{action_name}"
107
+ )
108
+ end
61
109
  end
62
110
  ```
63
111
 
112
+ `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.
113
+
64
114
  ## Manual reporting
65
115
 
66
116
  ```ruby
@@ -71,38 +121,89 @@ rescue => e
71
121
  end
72
122
  ```
73
123
 
124
+ Or scope context to a block:
125
+
126
+ ```ruby
127
+ OopsieExceptions.with_context(tenant_id: 42) do
128
+ do_work
129
+ end
130
+ ```
131
+
74
132
  ## Multiple webhooks
75
133
 
76
134
  ```ruby
77
- config.add_webhook "https://your-api.com/exceptions"
135
+ config.add_webhook "https://your-api.com/exceptions",
136
+ headers: { "Authorization" => "Bearer #{ENV['PRIMARY_TOKEN']}" }
137
+
78
138
  config.add_webhook "https://hooks.slack.com/services/..."
79
139
  config.add_webhook "https://discord.com/api/webhooks/..."
80
140
  ```
81
141
 
82
- Each endpoint receives every exception.
142
+ Each endpoint receives every exception. Failures on one webhook don't affect the others.
83
143
 
84
144
  ## Payload format
85
145
 
86
- Each webhook receives a JSON payload with:
146
+ Each webhook receives a JSON POST with:
87
147
 
88
148
  - **exception** — class, message, backtrace, cause chain
89
149
  - **request** — URL, method, IP, params, headers, user agent
90
- - **context** — user info and custom data you set
150
+ - **context** — `user`, `action`, `job`, plus anything you set via `context_builder` / `set_context`
91
151
  - **server** — hostname, PID, Ruby/Rails versions
92
- - **app** — name, environment
152
+ - **app** — `app_name`, `environment`
93
153
  - **timestamp** — UTC ISO8601
154
+ - **handled** — `true` for `OopsieExceptions.report(..., handled: true)`, `false` for unhandled
155
+
156
+ ## Filtering noise
157
+
158
+ By default these are dropped:
159
+
160
+ ```
161
+ ActionController::RoutingError
162
+ ActionController::UnknownFormat
163
+ ActionController::BadRequest
164
+ ActionDispatch::Http::MimeNegotiation::InvalidType
165
+ AbstractController::ActionNotFound
166
+ ActiveRecord::RecordNotFound
167
+ ActionController::UnknownHttpMethod
168
+ ```
94
169
 
95
- ## Filtering
170
+ Add your own:
96
171
 
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.
172
+ ```ruby
173
+ config.ignore_exception "MyApp::IgnorableError", "ThirdParty::Timeout"
174
+ ```
98
175
 
99
- Customize in your initializer:
176
+ Sensitive params (`password`, `token`, `secret`, `api_key`) and headers (`Authorization`, `Cookie`, `Set-Cookie`) are redacted from payloads automatically.
177
+
178
+ ## Mutating or dropping payloads
100
179
 
101
180
  ```ruby
102
- config.ignored_exceptions += ["MyApp::IgnorableError"]
103
- config.filter_parameters += [:credit_card]
181
+ config.before_notify = ->(payload) {
182
+ payload[:context][:deploy_sha] = ENV["GIT_SHA"]
183
+ return nil if payload[:exception][:message].include?("known noise")
184
+ payload
185
+ }
104
186
  ```
105
187
 
188
+ Return `nil` to drop the notification entirely.
189
+
190
+ ## Upgrading from earlier versions
191
+
192
+ 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.
193
+
194
+ ```bash
195
+ rm app/jobs/oopsie_exceptions/delivery_job.rb
196
+ rmdir app/jobs/oopsie_exceptions # if empty
197
+ ```
198
+
199
+ Or just re-run the install generator and it will detect and remove the legacy file for you:
200
+
201
+ ```bash
202
+ bin/rails generate oopsie_exceptions:install
203
+ ```
204
+
205
+ No initializer changes are required.
206
+
106
207
  ## License
107
208
 
108
209
  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,17 +25,26 @@ 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?
33
+
34
+ # Exceptions that won't be reported (404s, bot garbage, etc.)
35
+ # config.ignore_exception "MyApp::IgnorableError"
36
+
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.
39
+ # config.context_builder = ->(env) {
40
+ # warden = env["warden"]
41
+ # user = warden&.user
42
+ # params = env["action_dispatch.request.path_parameters"]
43
+ # {
44
+ # user: user ? { id: user.id, email: user.email } : nil,
45
+ # action: params ? "#{params[:controller]}##{params[:action]}" : nil
46
+ # }.compact
47
+ # }
24
48
 
25
49
  # Optional: modify or drop payloads before sending
26
50
  # config.before_notify = ->(payload) {
@@ -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
@@ -7,13 +7,15 @@ module OopsieExceptions
7
7
  :filter_headers, :capture_request_body,
8
8
  :async_delivery, :timeout, :open_timeout,
9
9
  :backtrace_cleaner, :before_notify,
10
- :enabled
10
+ :context_builder, :logger, :enabled
11
11
 
12
12
  def initialize
13
13
  @webhook_urls = []
14
14
  @app_name = defined?(Rails) ? (Rails.application.class.module_parent_name rescue "App") : "App"
15
- @environment = defined?(Rails) ? (Rails.env rescue "development") : "development"
15
+ @environment = defined?(Rails) ? (Rails.env rescue "development") : (ENV["RACK_ENV"] || "development")
16
16
  @ignored_exceptions = default_ignored_exceptions
17
+ @context_builder = nil
18
+ @logger = defined?(Rails.logger) && Rails.logger ? Rails.logger : nil
17
19
  @filter_parameters = %w[password password_confirmation secret token api_key]
18
20
  @filter_headers = %w[Authorization Cookie Set-Cookie]
19
21
  @capture_request_body = false
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rack"
4
+
3
5
  module OopsieExceptions
4
6
  module Context
5
7
  THREAD_KEY = :oopsie_exceptions_context
@@ -22,18 +24,18 @@ module OopsieExceptions
22
24
  end
23
25
 
24
26
  def from_rack_env(env)
25
- request = ActionDispatch::Request.new(env)
27
+ request = Rack::Request.new(env)
26
28
  config = OopsieExceptions.configuration
27
29
 
28
30
  ctx = {
29
31
  request: {
30
- url: request.original_url,
31
- method: request.method,
32
- ip: request.remote_ip,
33
- user_agent: request.user_agent,
34
- referer: request.referer,
35
- request_id: request.request_id,
36
- params: sanitize_params(request.filtered_parameters),
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),
37
39
  headers: extract_headers(env, config)
38
40
  }
39
41
  }
@@ -41,7 +43,7 @@ module OopsieExceptions
41
43
  if config.capture_request_body && request.content_type&.include?("application/json")
42
44
  body = request.body.read
43
45
  request.body.rewind
44
- ctx[:request][:body] = body.truncate(10_000) if body.present?
46
+ ctx[:request][:body] = body[0, 10_000] if body && !body.empty?
45
47
  end
46
48
 
47
49
  ctx
@@ -49,8 +51,12 @@ module OopsieExceptions
49
51
 
50
52
  private
51
53
 
52
- def sanitize_params(params)
53
- params.except("controller", "action").to_h
54
+ def sanitize_params(params, config)
55
+ filtered = params.reject { |k, _| k == "controller" || k == "action" }
56
+ filter_keys = config.filter_parameters
57
+ filtered.each_with_object({}) do |(k, v), hash|
58
+ hash[k] = filter_keys.any? { |f| k.to_s.include?(f) } ? "[FILTERED]" : v
59
+ end
54
60
  rescue
55
61
  {}
56
62
  end
@@ -13,6 +13,11 @@ module OopsieExceptions
13
13
  request_context = Context.from_rack_env(env)
14
14
  Context.merge(request_context)
15
15
 
16
+ if OopsieExceptions.configuration.context_builder
17
+ extra = OopsieExceptions.configuration.context_builder.call(env)
18
+ Context.merge(extra) if extra.is_a?(Hash)
19
+ end
20
+
16
21
  response = @app.call(env)
17
22
 
18
23
  if response[0].to_i >= 500
@@ -6,6 +6,13 @@ module OopsieExceptions
6
6
  app.middleware.insert_after ActionDispatch::DebugExceptions, OopsieExceptions::Middleware
7
7
  end
8
8
 
9
+ initializer "oopsie_exceptions.active_job" do
10
+ ActiveSupport.on_load(:active_job) do
11
+ ::ActiveJob::Base.extend(OopsieExceptions::ActiveJobExtension)
12
+ require "oopsie_exceptions/webhook_job"
13
+ end
14
+ end
15
+
9
16
  initializer "oopsie_exceptions.error_subscriber", after: :load_config_initializers do
10
17
  if Rails.respond_to?(:error) && OopsieExceptions.configuration.enabled
11
18
  Rails.error.subscribe(OopsieExceptions::ErrorSubscriber.new)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OopsieExceptions
4
- VERSION = "0.1.0"
4
+ VERSION = "1.1.0"
5
5
  end
@@ -27,30 +27,24 @@ module OopsieExceptions
27
27
  response = http.request(request)
28
28
 
29
29
  unless response.is_a?(Net::HTTPSuccess)
30
- log_warn("Webhook #{webhook.name} responded #{response.code}: #{response.body.to_s[0, 500]}")
30
+ log(:warn, "Webhook #{webhook.name} responded #{response.code}: #{response.body.to_s[0, 500]}")
31
31
  end
32
32
 
33
33
  response
34
34
  rescue => e
35
- log_error("Failed to deliver to #{webhook.name}: #{e.message}")
35
+ log(:error, "Failed to deliver to #{webhook.name}: #{e.message}")
36
36
  nil
37
37
  end
38
38
 
39
39
  private
40
40
 
41
- def log_warn(message)
42
- if defined?(Rails.logger) && Rails.logger
43
- Rails.logger.warn("[OopsieExceptions] #{message}")
41
+ def log(level, message)
42
+ logger = OopsieExceptions.configuration.logger
43
+ formatted = "[OopsieExceptions] #{message}"
44
+ if logger
45
+ logger.send(level, formatted)
44
46
  else
45
- warn("[OopsieExceptions] #{message}")
46
- end
47
- end
48
-
49
- def log_error(message)
50
- if defined?(Rails.logger) && Rails.logger
51
- Rails.logger.error("[OopsieExceptions] #{message}")
52
- else
53
- warn("[OopsieExceptions] ERROR: #{message}")
47
+ warn(formatted)
54
48
  end
55
49
  end
56
50
  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,60 +1,33 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: oopsie_exceptions
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Troy
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-04 00:00:00.000000000 Z
11
+ date: 2026-04-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: railties
14
+ name: rack
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '7.0'
19
+ version: '2.0'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '7.0'
27
- - !ruby/object:Gem::Dependency
28
- name: activesupport
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - ">="
32
- - !ruby/object:Gem::Version
33
- version: '7.0'
34
- type: :runtime
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - ">="
39
- - !ruby/object:Gem::Version
40
- version: '7.0'
41
- - !ruby/object:Gem::Dependency
42
- name: actionpack
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - ">="
46
- - !ruby/object:Gem::Version
47
- version: '7.0'
48
- type: :runtime
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - ">="
53
- - !ruby/object:Gem::Version
54
- version: '7.0'
26
+ version: '2.0'
55
27
  description: Captures unhandled exceptions from web requests and background jobs,
56
28
  enriches them with request/user/server context, and delivers structured JSON payloads
57
- to configurable webhook endpoints.
29
+ to configurable webhook endpoints. Works with any Rack-based framework; optional
30
+ Rails integration included.
58
31
  email:
59
32
  executables: []
60
33
  extensions: []
@@ -63,9 +36,9 @@ files:
63
36
  - LICENSE.txt
64
37
  - README.md
65
38
  - lib/generators/oopsie_exceptions/install_generator.rb
66
- - lib/generators/oopsie_exceptions/templates/delivery_job.rb
67
39
  - lib/generators/oopsie_exceptions/templates/initializer.rb
68
40
  - lib/oopsie_exceptions.rb
41
+ - lib/oopsie_exceptions/active_job_extension.rb
69
42
  - lib/oopsie_exceptions/configuration.rb
70
43
  - lib/oopsie_exceptions/context.rb
71
44
  - lib/oopsie_exceptions/error_subscriber.rb
@@ -74,6 +47,7 @@ files:
74
47
  - lib/oopsie_exceptions/railtie.rb
75
48
  - lib/oopsie_exceptions/version.rb
76
49
  - lib/oopsie_exceptions/webhook_client.rb
50
+ - lib/oopsie_exceptions/webhook_job.rb
77
51
  homepage: https://github.com/theinventor/oopsie_exceptions
78
52
  licenses:
79
53
  - MIT
@@ -96,8 +70,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
96
70
  - !ruby/object:Gem::Version
97
71
  version: '0'
98
72
  requirements: []
99
- rubygems_version: 3.5.23
73
+ rubygems_version: 3.5.3
100
74
  signing_key:
101
75
  specification_version: 4
102
- summary: Lightweight exception capture and webhook delivery for Rails
76
+ summary: Lightweight exception capture and webhook delivery for Ruby (framework-agnostic)
103
77
  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