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 +4 -4
- data/lib/oopsie/client.rb +4 -8
- data/lib/oopsie/context_builder.rb +52 -0
- data/lib/oopsie/exception_chain_builder.rb +114 -0
- data/lib/oopsie/middleware.rb +7 -1
- data/lib/oopsie/railtie.rb +10 -1
- data/lib/oopsie/sidekiq.rb +9 -2
- data/lib/oopsie/version.rb +1 -1
- data/lib/oopsie.rb +28 -12
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b253a01f87bd139b82b076c5a004abc1e5ea133a35fdd4d163b0d40c9afae0af
|
|
4
|
+
data.tar.gz: 9df3a4fac0ddca3431a0dd21e22f2e382736c9741c6a30c71180a33c51501d70
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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(
|
|
19
|
+
def send_error(**payload)
|
|
20
20
|
uri = URI.join(@configuration.endpoint, ERRORS_PATH)
|
|
21
|
-
request = build_request(uri,
|
|
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,
|
|
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
|
data/lib/oopsie/middleware.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/oopsie/railtie.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/oopsie/sidekiq.rb
CHANGED
|
@@ -5,8 +5,15 @@ require 'oopsie'
|
|
|
5
5
|
module Oopsie
|
|
6
6
|
module Sidekiq
|
|
7
7
|
class ErrorHandler
|
|
8
|
-
def call(exception, *)
|
|
9
|
-
|
|
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
|
data/lib/oopsie/version.rb
CHANGED
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.
|
|
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
|