oopsie_exceptions 1.1.0 → 1.1.2

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: a3c1bf7176b541c4c19f09ca56724e8c873e1bae149ac9ff9963a4f4fb4f1156
4
- data.tar.gz: 692fd55d89e3235279b1ba98764f1fbeaeab5e11204c3733674f7a8958956262
3
+ metadata.gz: 49072b73da34cefc5bf2c080d3bad5f4d0d6f0494225ae8a6e4140488aa00744
4
+ data.tar.gz: 85a4877ef4f61c7a81932e1b607d2d0a637ecfaebff111b54eb1a59ded96a496
5
5
  SHA512:
6
- metadata.gz: 0c8a3bfd9907819f35ecd979d114e9784daf71ff7c5b2264246782458463c15271a71319208fe5798de1c11178c7bf643e8bcda693124ed74e7f2a50cc25ef17
7
- data.tar.gz: 9d4f5168d31b516127abc0afa2e894ec02c28cdf076e82f96d555f81c1e38fb3884d3fbca46307572ce2f9749798cf1ace9c4e2fa6d98a6263908c4a695a14a1
6
+ metadata.gz: 7b1936f78e392b54e8fb27ab5c12fdfcf9c95ff2ffb9a86bd6a06b4c6eb35df914caa6eca293e8a1a755cfff224ec9527c944dfc413b89e381f64df29ca32f37
7
+ data.tar.gz: 5103181f96b85219c367bf6b2df92ce6492d25a98cba35a12868ca2b1f0cfc5a0a239360563d1d45fdfbb2496c6a1ce1a820e542bf974658f62b8014bd58d70e
data/README.md CHANGED
@@ -90,6 +90,8 @@ end
90
90
 
91
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
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
+
93
95
  ## Adding context per-request
94
96
 
95
97
  If you don't want to use `context_builder`, you can set context from a controller:
@@ -189,6 +191,14 @@ Return `nil` to drop the notification entirely.
189
191
 
190
192
  ## Upgrading from earlier versions
191
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
+
192
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.
193
203
 
194
204
  ```bash
@@ -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
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OopsieExceptions
4
+ class ExceptionNormalizer
5
+ RETRY_WRAPPER_CLASS_NAMES = %w[
6
+ Sidekiq::JobRetry::Handled
7
+ ].freeze
8
+
9
+ Result = Struct.new(:exception, :context, :wrappers, keyword_init: true)
10
+
11
+ class << self
12
+ def normalize(exception, context:)
13
+ wrappers = collect_retry_wrappers(exception)
14
+ return Result.new(exception: exception, context: context, wrappers: []) if wrappers.empty?
15
+
16
+ Result.new(
17
+ exception: wrappers.last.cause,
18
+ context: merge_wrapper_context(context, wrappers, wrappers.last.cause),
19
+ wrappers: wrappers
20
+ )
21
+ end
22
+
23
+ private
24
+
25
+ def collect_retry_wrappers(exception)
26
+ wrappers = []
27
+ current = exception
28
+ seen = {}
29
+
30
+ while retry_wrapper?(current) && current.cause && !seen[current.__id__]
31
+ seen[current.__id__] = true
32
+ wrappers << current
33
+ current = current.cause
34
+ end
35
+
36
+ wrappers
37
+ end
38
+
39
+ def retry_wrapper?(exception)
40
+ RETRY_WRAPPER_CLASS_NAMES.include?(exception.class.name)
41
+ end
42
+
43
+ def merge_wrapper_context(context, wrappers, normalized_exception)
44
+ merged_context = context.dup
45
+ metadata = wrappers.map do |wrapper|
46
+ wrapper_metadata(wrapper, normalized_exception, context)
47
+ end
48
+
49
+ if merged_context.key?(:exception_wrapper) || merged_context.key?("exception_wrapper")
50
+ merged_context[:oopsie_exception_wrapper] = metadata.first
51
+ else
52
+ merged_context[:exception_wrapper] = metadata.first
53
+ end
54
+
55
+ merged_context[:exception_wrappers] = metadata if metadata.length > 1
56
+ merged_context
57
+ end
58
+
59
+ def wrapper_metadata(wrapper, normalized_exception, context)
60
+ metadata = {
61
+ class_name: wrapper.class.name,
62
+ message: wrapper.message.to_s[0, 1_000],
63
+ first_line: parse_backtrace_line(wrapper.backtrace&.first),
64
+ normalized_exception: {
65
+ class_name: normalized_exception.class.name,
66
+ message: normalized_exception.message.to_s[0, 1_000]
67
+ }
68
+ }
69
+
70
+ source = context[:source] || context["source"]
71
+ metadata[:source] = source if source
72
+ metadata
73
+ end
74
+
75
+ def parse_backtrace_line(line)
76
+ return nil unless line
77
+
78
+ match = line.match(/\A(.+):(\d+):in [`'](.+)'\z/)
79
+ return { raw: line } unless match
80
+
81
+ {
82
+ file: match[1],
83
+ line: match[2].to_i,
84
+ method: match[3]
85
+ }
86
+ end
87
+ end
88
+ end
89
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OopsieExceptions
4
- VERSION = "1.1.0"
4
+ VERSION = "1.1.2"
5
5
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "oopsie_exceptions/version"
4
4
  require_relative "oopsie_exceptions/configuration"
5
5
  require_relative "oopsie_exceptions/context"
6
+ require_relative "oopsie_exceptions/exception_normalizer"
6
7
  require_relative "oopsie_exceptions/payload"
7
8
  require_relative "oopsie_exceptions/webhook_client"
8
9
  require_relative "oopsie_exceptions/middleware"
@@ -24,12 +25,17 @@ module OopsieExceptions
24
25
 
25
26
  def report(exception, context: {}, handled: true)
26
27
  return unless configuration.enabled
27
- return if configuration.ignored?(exception)
28
- return if exception.instance_variable_get(REPORTED_MARKER)
28
+ normalized = ExceptionNormalizer.normalize(exception, context: context)
29
+ reportable_exception = normalized.exception
29
30
 
30
- exception.instance_variable_set(REPORTED_MARKER, true)
31
+ return if configuration.ignored?(reportable_exception)
32
+ return if reportable_exception.instance_variable_get(REPORTED_MARKER)
31
33
 
32
- payload = Payload.build(exception, context: context, handled: handled)
34
+ ([reportable_exception] + normalized.wrappers).each do |reported_exception|
35
+ reported_exception.instance_variable_set(REPORTED_MARKER, true)
36
+ end
37
+
38
+ payload = Payload.build(reportable_exception, context: normalized.context, handled: handled)
33
39
 
34
40
  if configuration.before_notify
35
41
  payload = configuration.before_notify.call(payload)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: oopsie_exceptions
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.1.2
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-06 00:00:00.000000000 Z
11
+ date: 2026-07-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '5.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '5.0'
27
41
  description: Captures unhandled exceptions from web requests and background jobs,
28
42
  enriches them with request/user/server context, and delivers structured JSON payloads
29
43
  to configurable webhook endpoints. Works with any Rack-based framework; optional
@@ -42,6 +56,7 @@ files:
42
56
  - lib/oopsie_exceptions/configuration.rb
43
57
  - lib/oopsie_exceptions/context.rb
44
58
  - lib/oopsie_exceptions/error_subscriber.rb
59
+ - lib/oopsie_exceptions/exception_normalizer.rb
45
60
  - lib/oopsie_exceptions/middleware.rb
46
61
  - lib/oopsie_exceptions/payload.rb
47
62
  - lib/oopsie_exceptions/railtie.rb
@@ -70,7 +85,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
70
85
  - !ruby/object:Gem::Version
71
86
  version: '0'
72
87
  requirements: []
73
- rubygems_version: 3.5.3
88
+ rubygems_version: 3.5.23
74
89
  signing_key:
75
90
  specification_version: 4
76
91
  summary: Lightweight exception capture and webhook delivery for Ruby (framework-agnostic)