oopsie-ruby 0.2.3 → 0.3.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: 90e505d931c11b4c59ca0f376c0ee0ad394e21c3407831d32b2ef79f918141fe
4
- data.tar.gz: 5c7fb498a876bded8a1dda466682632888999d4383d945f27b0dc8de92fd71b4
3
+ metadata.gz: b253a01f87bd139b82b076c5a004abc1e5ea133a35fdd4d163b0d40c9afae0af
4
+ data.tar.gz: 9df3a4fac0ddca3431a0dd21e22f2e382736c9741c6a30c71180a33c51501d70
5
5
  SHA512:
6
- metadata.gz: 542e41ff7f9dbd60a0e8a2ab3fd243dca4c7d0ee030ea569be2a7d0d6630b00c59a0501faff05fc6e2962d8ff05b4092e1c5d56bc08359a7f75c2498b691b06b
7
- data.tar.gz: 8b15abe36e4387f909ed3f8799587d42a9fe22de9303dc283eb39f8197774d44243c6c9c232ed0edf511fcf3efa13393f11b7c99f44d70e569f09b09e7f2cd3f
6
+ metadata.gz: 92c526acf4f16a3e78196c2dce640a9df09911ba4a2a1133a50de311919bcc69e2556afbfe34463a21ad78f10eda6fa1c00b66aff7f6b9b643941582b3e19ae4
7
+ data.tar.gz: '087b6a2d51840d8ada9194c832ff94789a71b08c63bdbe9c9da3d2055b823c914a13d88f273188782a0a3cbada61be3e7808b6b8a02b1f2f9b4bc8c156cf9aae'
data/lib/oopsie/client.rb CHANGED
@@ -16,9 +16,9 @@ module Oopsie
16
16
  @configuration = configuration
17
17
  end
18
18
 
19
- def send_error(error_class:, message:, stack_trace:)
19
+ def send_error(**payload)
20
20
  uri = URI.join(@configuration.endpoint, ERRORS_PATH)
21
- request = build_request(uri, error_class:, message:, stack_trace:)
21
+ request = build_request(uri, payload)
22
22
  response = execute(uri, request)
23
23
  handle_response(response)
24
24
  rescue StandardError => e
@@ -27,15 +27,11 @@ module Oopsie
27
27
 
28
28
  private
29
29
 
30
- def build_request(uri, error_class:, message:, stack_trace:)
30
+ def build_request(uri, payload)
31
31
  request = Net::HTTP::Post.new(uri)
32
32
  request['Content-Type'] = 'application/json'
33
33
  request['Authorization'] = "Bearer #{@configuration.api_key}"
34
- request.body = JSON.generate(
35
- error_class: error_class,
36
- message: message,
37
- stack_trace: stack_trace
38
- )
34
+ request.body = JSON.generate(payload)
39
35
  request
40
36
  end
41
37
 
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oopsie
4
+ # Builds execution_context hashes for the Oopsie API.
5
+ # Only includes routing/metadata — never request bodies, sensitive headers
6
+ # (auth, cookies), or job arguments (which may contain PII).
7
+ # Note: query strings are excluded from the URL as they may contain sensitive parameters.
8
+ module ContextBuilder
9
+ DATA_KEYS = %i[job_class queue jid retry_count].freeze
10
+
11
+ module_function
12
+
13
+ def from_rack_env(env)
14
+ method = env['REQUEST_METHOD'] || 'GET'
15
+ path = env['PATH_INFO'] || '/'
16
+ data = build_http_data(env, method, path)
17
+
18
+ { type: 'http', description: "#{method} #{path}", data: data }
19
+ end
20
+
21
+ # Accepts both string and symbol keys (Sidekiq normalises to strings,
22
+ # but callers may use symbols).
23
+ def from_sidekiq(job_hash)
24
+ values = resolve_sidekiq_values(job_hash)
25
+ description = "#{values[:display_class] || values[:job_class] || 'Unknown'}#perform"
26
+ data = DATA_KEYS.each_with_object({}) { |k, h| h[k] = values[k] unless values[k].nil? }
27
+
28
+ { type: 'worker', description: description, data: data }
29
+ end
30
+
31
+ def default
32
+ { type: 'unknown' }
33
+ end
34
+
35
+ def build_http_data(env, method, path)
36
+ data = { method: method, url: path }
37
+ data[:content_type] = env['CONTENT_TYPE'] if env['CONTENT_TYPE']
38
+ data[:request_id] = env['HTTP_X_REQUEST_ID'] if env['HTTP_X_REQUEST_ID']
39
+ data
40
+ end
41
+
42
+ def resolve_sidekiq_values(job_hash)
43
+ {
44
+ job_class: job_hash.key?('class') ? job_hash['class'] : job_hash[:class],
45
+ display_class: job_hash.key?('display_class') ? job_hash['display_class'] : job_hash[:display_class],
46
+ queue: job_hash.key?('queue') ? job_hash['queue'] : job_hash[:queue],
47
+ jid: job_hash.key?('jid') ? job_hash['jid'] : job_hash[:jid],
48
+ retry_count: job_hash.key?('retry_count') ? job_hash['retry_count'] : job_hash[:retry_count]
49
+ }
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oopsie
4
+ # Walks Exception#cause and builds the exception_chain array for the Oopsie API.
5
+ # Entries are ordered root-cause-first, outermost-last.
6
+ module ExceptionChainBuilder
7
+ MAX_CHAIN_LENGTH = 20 # Oopsie API limit
8
+ MAX_FRAMES = 100 # Oopsie API limit per exception entry
9
+ NOT_IN_APP_PATTERNS = ['/gems/', '/ruby/', '/vendor/', '<internal:'].freeze
10
+ # Ruby < 3.4 uses backtick+quote (`method'), Ruby >= 3.4 uses quote+quote ('method')
11
+ BACKTRACE_REGEX = /\A(.+):(\d+):in\s+[`'](.+)'\z/
12
+
13
+ module_function
14
+
15
+ def build(exception)
16
+ chain = unwind(exception)
17
+ # Deduplicate by backtrace object identity — if the same backtrace array
18
+ # is shared across exceptions (e.g., re-raised exceptions or manual set_backtrace),
19
+ # we avoid re-parsing it.
20
+ parsed_backtrace_ids = {}.compare_by_identity
21
+
22
+ chain.each_with_index.map do |ex, index|
23
+ stacktrace = deduplicated_stacktrace(ex, parsed_backtrace_ids)
24
+ build_entry(ex, outermost: index == chain.length - 1, stacktrace: stacktrace)
25
+ end
26
+ end
27
+
28
+ def unwind(exception)
29
+ chain = []
30
+ current = exception
31
+ seen = {}.compare_by_identity
32
+
33
+ while current && chain.length < MAX_CHAIN_LENGTH
34
+ break if seen.key?(current) # guard against circular causes
35
+
36
+ seen[current] = true
37
+ chain.unshift(current)
38
+ current = current.cause
39
+ end
40
+
41
+ chain
42
+ end
43
+
44
+ def deduplicated_stacktrace(exception, parsed_backtrace_ids)
45
+ bt = exception.backtrace
46
+ return [] if bt.nil?
47
+ return parsed_backtrace_ids[bt] if parsed_backtrace_ids.key?(bt)
48
+
49
+ parsed_backtrace_ids[bt] = parse_backtrace(bt)
50
+ end
51
+
52
+ def build_entry(exception, outermost:, stacktrace:)
53
+ mechanism_type = outermost ? 'generic' : 'chained'
54
+
55
+ {
56
+ type: exception.class.name,
57
+ value: exception_message(exception),
58
+ mechanism: { type: mechanism_type, handled: false },
59
+ stacktrace: stacktrace
60
+ }
61
+ end
62
+
63
+ # Prefers detailed_message (Ruby 3.2+) for richer context, ensures valid UTF-8.
64
+ def exception_message(exception)
65
+ msg = raw_message(exception)
66
+ msg = msg.to_s unless msg.is_a?(String)
67
+ encode_utf8(msg)
68
+ rescue StandardError
69
+ fallback_message(exception)
70
+ end
71
+
72
+ def raw_message(exception)
73
+ if exception.respond_to?(:detailed_message)
74
+ exception.detailed_message(highlight: false)
75
+ else
76
+ exception.message
77
+ end
78
+ end
79
+
80
+ def fallback_message(exception)
81
+ encode_utf8(exception.message.to_s)
82
+ rescue StandardError => e
83
+ "(failed to retrieve exception message: #{e.class})"
84
+ end
85
+
86
+ def encode_utf8(str)
87
+ str.encode('UTF-8', invalid: :replace, undef: :replace, replace: "\uFFFD")
88
+ end
89
+
90
+ def parse_backtrace(backtrace)
91
+ backtrace.first(MAX_FRAMES).map { |line| parse_frame(line) }
92
+ end
93
+
94
+ def parse_frame(line)
95
+ match = BACKTRACE_REGEX.match(line)
96
+ return { file: line, function: '', lineno: 0, in_app: in_app?(line) } unless match
97
+
98
+ file = match[1]
99
+ {
100
+ file: file,
101
+ function: match[3],
102
+ lineno: match[2].to_i,
103
+ in_app: in_app?(file)
104
+ }
105
+ end
106
+
107
+ # Negative-match heuristic: anything under /gems/, /ruby/, /vendor/, or
108
+ # <internal: is not in-app. Zero-config alternative to Sentry's positive-match
109
+ # approach which requires knowing project_root.
110
+ def in_app?(file)
111
+ NOT_IN_APP_PATTERNS.none? { |pattern| file.include?(pattern) }
112
+ end
113
+ end
114
+ end
@@ -9,7 +9,13 @@ module Oopsie
9
9
  def call(env)
10
10
  @app.call(env)
11
11
  rescue Exception => e # rubocop:disable Lint/RescueException
12
- Oopsie.report(e)
12
+ context = begin
13
+ Oopsie::ContextBuilder.from_rack_env(env)
14
+ rescue StandardError => context_error
15
+ Oopsie.safely_notify_error(context_error)
16
+ nil
17
+ end
18
+ Oopsie.report(e, context: context)
13
19
  raise
14
20
  end
15
21
  end
@@ -10,10 +10,19 @@ module Oopsie
10
10
  app.middleware.insert_before ActionDispatch::ShowExceptions, Oopsie::Middleware
11
11
  end
12
12
 
13
+ # Catches exceptions raised during controller actions that Rails surfaces in
14
+ # the notification payload. Exceptions fully handled by rescue_from will NOT appear here.
13
15
  initializer 'oopsie.subscribe' do
14
16
  ActiveSupport::Notifications.subscribe('process_action.action_controller') do |event|
15
17
  if (exception = event.payload[:exception_object])
16
- Oopsie.report(exception)
18
+ context = begin
19
+ env = event.payload[:headers]&.env
20
+ env ? Oopsie::ContextBuilder.from_rack_env(env) : nil
21
+ rescue StandardError => e
22
+ Oopsie.safely_notify_error(e)
23
+ nil
24
+ end
25
+ Oopsie.report(exception, context: context)
17
26
  end
18
27
  end
19
28
  end
@@ -5,8 +5,15 @@ require 'oopsie'
5
5
  module Oopsie
6
6
  module Sidekiq
7
7
  class ErrorHandler
8
- def call(exception, *)
9
- Oopsie.report(exception)
8
+ def call(exception, context = {}, *)
9
+ ctx = begin
10
+ job = context[:job] || context['job'] || {}
11
+ Oopsie::ContextBuilder.from_sidekiq(job)
12
+ rescue StandardError => e
13
+ Oopsie.safely_notify_error(e)
14
+ nil
15
+ end
16
+ Oopsie.report(exception, context: ctx)
10
17
  end
11
18
  end
12
19
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Oopsie
4
- VERSION = '0.2.3'
4
+ VERSION = '0.3.0'
5
5
  end
data/lib/oopsie.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  require_relative 'oopsie/version'
4
4
  require_relative 'oopsie/configuration'
5
5
  require_relative 'oopsie/client'
6
+ require_relative 'oopsie/exception_chain_builder'
7
+ require_relative 'oopsie/context_builder'
6
8
  require_relative 'oopsie/middleware'
7
9
  require_relative 'oopsie/railtie' if defined?(Rails::Railtie)
8
10
  require_relative 'oopsie/sidekiq' if defined?(Sidekiq)
@@ -21,22 +23,42 @@ module Oopsie
21
23
  @configuration = Configuration.new
22
24
  end
23
25
 
24
- def report(exception)
26
+ def report(exception, context: nil)
25
27
  return if skip_report?(exception)
26
28
 
27
29
  tag_reported(exception)
28
30
  configuration.validate!
29
- Client.new(configuration).send_error(
30
- error_class: exception.class.name,
31
- message: exception.message,
32
- stack_trace: exception.backtrace&.join("\n")
33
- )
31
+ Client.new(configuration).send_error(**build_payload(exception, context))
34
32
  rescue StandardError => e
35
33
  safely_notify_error(e)
36
34
  end
37
35
 
36
+ def safely_notify_error(error)
37
+ configuration.on_error&.call(error)
38
+ rescue StandardError
39
+ # Never crash the host app
40
+ end
41
+
38
42
  private
39
43
 
44
+ # stack_trace kept for backwards compat; backend prefers exception_chain when present
45
+ def build_payload(exception, context)
46
+ {
47
+ error_class: exception.class.name,
48
+ message: exception.message,
49
+ stack_trace: exception.backtrace&.join("\n"),
50
+ exception_chain: safe_build_chain(exception),
51
+ execution_context: context
52
+ }
53
+ end
54
+
55
+ def safe_build_chain(exception)
56
+ ExceptionChainBuilder.build(exception)
57
+ rescue StandardError => e
58
+ safely_notify_error(e)
59
+ nil
60
+ end
61
+
40
62
  def skip_report?(exception)
41
63
  configuration.ignored_exceptions.any? { |klass| exception.is_a?(klass) } ||
42
64
  exception.instance_variable_get(:@_oopsie_reported)
@@ -47,11 +69,5 @@ module Oopsie
47
69
  rescue FrozenError
48
70
  # Frozen exceptions can't be tagged — skip dedup, proceed with reporting
49
71
  end
50
-
51
- def safely_notify_error(error)
52
- configuration.on_error&.call(error)
53
- rescue StandardError
54
- # Never crash the host app
55
- end
56
72
  end
57
73
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: oopsie-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oopsie
@@ -147,6 +147,8 @@ files:
147
147
  - lib/oopsie.rb
148
148
  - lib/oopsie/client.rb
149
149
  - lib/oopsie/configuration.rb
150
+ - lib/oopsie/context_builder.rb
151
+ - lib/oopsie/exception_chain_builder.rb
150
152
  - lib/oopsie/middleware.rb
151
153
  - lib/oopsie/railtie.rb
152
154
  - lib/oopsie/sidekiq.rb