errorgap 0.8.0 → 0.9.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/CHANGELOG.md +48 -0
- data/README.md +44 -1
- data/exe/errorgap +17 -1
- data/lib/errorgap/configuration.rb +44 -2
- data/lib/errorgap/delivery.rb +248 -0
- data/lib/errorgap/integrations/active_job.rb +58 -0
- data/lib/errorgap/integrations/net_http.rb +50 -0
- data/lib/errorgap/integrations/sidekiq.rb +56 -0
- data/lib/errorgap/log_delivery.rb +2 -25
- data/lib/errorgap/notice.rb +10 -21
- data/lib/errorgap/notifier.rb +27 -21
- data/lib/errorgap/rack_middleware.rb +48 -5
- data/lib/errorgap/rails/railtie.rb +8 -0
- data/lib/errorgap/release_detector.rb +69 -0
- data/lib/errorgap/sanitizer.rb +91 -0
- data/lib/errorgap/sign_ins.rb +2 -19
- data/lib/errorgap/transacter.rb +2 -24
- data/lib/errorgap/version.rb +1 -1
- data/lib/errorgap.rb +108 -6
- metadata +8 -2
data/lib/errorgap/notice.rb
CHANGED
|
@@ -11,6 +11,7 @@ module Errorgap
|
|
|
11
11
|
MAX_SOURCE_FRAMES = 25
|
|
12
12
|
MAX_SOURCE_LINE_LENGTH = 400
|
|
13
13
|
MAX_CAUSE_DEPTH = 10
|
|
14
|
+
MAX_MESSAGE_LENGTH = 8192
|
|
14
15
|
|
|
15
16
|
def self.from_exception(error, configuration:, context: {}, environment: {}, session: {}, params: {}, breadcrumbs: [])
|
|
16
17
|
new(
|
|
@@ -40,15 +41,15 @@ module Errorgap
|
|
|
40
41
|
received_at: Time.now.utc.iso8601,
|
|
41
42
|
errors: [
|
|
42
43
|
{
|
|
43
|
-
type: @error.class.name,
|
|
44
|
-
message: @error.message
|
|
44
|
+
type: @error.class.name.to_s,
|
|
45
|
+
message: Sanitizer.string(@error.message, MAX_MESSAGE_LENGTH),
|
|
45
46
|
backtrace: backtrace_frames
|
|
46
47
|
}
|
|
47
48
|
],
|
|
48
|
-
context: default_context.merge(
|
|
49
|
-
environment:
|
|
50
|
-
session:
|
|
51
|
-
params:
|
|
49
|
+
context: default_context.merge(sanitizer.clean(@context)),
|
|
50
|
+
environment: sanitizer.clean(@environment),
|
|
51
|
+
session: sanitizer.filter(@session),
|
|
52
|
+
params: sanitizer.filter(@params)
|
|
52
53
|
}
|
|
53
54
|
end
|
|
54
55
|
|
|
@@ -100,7 +101,7 @@ module Errorgap
|
|
|
100
101
|
# The causes beyond the root error, as {type, message} pairs.
|
|
101
102
|
def collect_causes
|
|
102
103
|
error_chain.drop(1).map do |link|
|
|
103
|
-
{ type: link.class.name, message: link.message
|
|
104
|
+
{ type: link.class.name.to_s, message: Sanitizer.string(link.message, MAX_MESSAGE_LENGTH) }
|
|
104
105
|
end
|
|
105
106
|
end
|
|
106
107
|
|
|
@@ -173,20 +174,8 @@ module Errorgap
|
|
|
173
174
|
absolute_path.sub(%r{\A#{Regexp.escape(root)}/?}, "")
|
|
174
175
|
end
|
|
175
176
|
|
|
176
|
-
def
|
|
177
|
-
|
|
178
|
-
filtered[key] = sensitive?(key) ? "[FILTERED]" : value
|
|
179
|
-
end
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
def sensitive?(key)
|
|
183
|
-
@configuration.filter_keys.any? { |filter| key.to_s.downcase.include?(filter.downcase) }
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
def stringify_hash(hash)
|
|
187
|
-
hash.each_with_object({}) do |(key, value), result|
|
|
188
|
-
result[key.to_s] = value.is_a?(Hash) ? stringify_hash(value) : value
|
|
189
|
-
end
|
|
177
|
+
def sanitizer
|
|
178
|
+
@sanitizer ||= Sanitizer.new(@configuration.filter_keys)
|
|
190
179
|
end
|
|
191
180
|
end
|
|
192
181
|
end
|
data/lib/errorgap/notifier.rb
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
require "net/http"
|
|
5
|
-
require "uri"
|
|
6
|
-
|
|
7
3
|
module Errorgap
|
|
8
4
|
class Notifier
|
|
9
5
|
Response = Struct.new(:status, :body, :error, keyword_init: true) do
|
|
@@ -33,11 +29,15 @@ module Errorgap
|
|
|
33
29
|
params: params,
|
|
34
30
|
breadcrumbs: breadcrumbs
|
|
35
31
|
)
|
|
32
|
+
payload = run_hooks(notice.to_h)
|
|
33
|
+
return Response.new(status: 202, body: "dropped by before_notify") unless payload
|
|
34
|
+
|
|
35
|
+
Errorgap.record_release_once if @configuration.track_release
|
|
36
36
|
|
|
37
37
|
if sync || !@configuration.async
|
|
38
|
-
deliver(
|
|
38
|
+
deliver(payload)
|
|
39
39
|
else
|
|
40
|
-
Errorgap.
|
|
40
|
+
Errorgap.delivery.enqueue("notices", payload)
|
|
41
41
|
Response.new(status: 202, body: "queued")
|
|
42
42
|
end
|
|
43
43
|
rescue StandardError => exception
|
|
@@ -45,18 +45,11 @@ module Errorgap
|
|
|
45
45
|
Response.new(error: exception)
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
# `notice` is a Notice or an already built payload Hash.
|
|
48
49
|
def deliver(notice)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
request["User-Agent"] = "errorgap-ruby/#{Errorgap::VERSION}"
|
|
53
|
-
request["X-Errorgap-Project-Key"] = @configuration.api_key if present?(@configuration.api_key)
|
|
54
|
-
request.body = JSON.generate(notice.to_h)
|
|
55
|
-
|
|
56
|
-
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
57
|
-
http.request(request)
|
|
58
|
-
end
|
|
59
|
-
Response.new(status: response.code.to_i, body: response.body)
|
|
50
|
+
payload = notice.respond_to?(:to_h) && !notice.is_a?(Hash) ? notice.to_h : notice
|
|
51
|
+
result = Errorgap.delivery.post("notices", payload)
|
|
52
|
+
Response.new(status: result.status, body: result.body, error: result.error)
|
|
60
53
|
rescue StandardError => exception
|
|
61
54
|
log(exception)
|
|
62
55
|
Response.new(error: exception)
|
|
@@ -64,12 +57,25 @@ module Errorgap
|
|
|
64
57
|
|
|
65
58
|
private
|
|
66
59
|
|
|
67
|
-
|
|
68
|
-
|
|
60
|
+
# A hook that raises is logged and skipped: a bug in a filter must not
|
|
61
|
+
# lose the error it was filtering.
|
|
62
|
+
def run_hooks(payload)
|
|
63
|
+
@configuration.before_notify_hooks.each do |hook|
|
|
64
|
+
begin
|
|
65
|
+
result = hook.call(payload)
|
|
66
|
+
rescue StandardError => exception
|
|
67
|
+
log(exception)
|
|
68
|
+
next
|
|
69
|
+
end
|
|
70
|
+
return nil if result == false
|
|
71
|
+
|
|
72
|
+
payload = result if result.is_a?(Hash) && result.key?(:errors)
|
|
73
|
+
end
|
|
74
|
+
payload
|
|
69
75
|
end
|
|
70
76
|
|
|
71
|
-
def
|
|
72
|
-
|
|
77
|
+
def log(exception)
|
|
78
|
+
@configuration.logger&.warn("[errorgap] #{exception.class}: #{exception.message}")
|
|
73
79
|
end
|
|
74
80
|
end
|
|
75
81
|
end
|
|
@@ -20,6 +20,7 @@ module Errorgap
|
|
|
20
20
|
notify_once(env, exception)
|
|
21
21
|
raise
|
|
22
22
|
ensure
|
|
23
|
+
Errorgap.clear_user
|
|
23
24
|
elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0
|
|
24
25
|
record_transaction(env, status || 500, elapsed_ms, transaction_id)
|
|
25
26
|
end
|
|
@@ -33,6 +34,8 @@ module Errorgap
|
|
|
33
34
|
rescue Exception => exception # rubocop:disable Lint/RescueException
|
|
34
35
|
notify_once(env, exception)
|
|
35
36
|
raise
|
|
37
|
+
ensure
|
|
38
|
+
Errorgap.clear_user
|
|
36
39
|
end
|
|
37
40
|
|
|
38
41
|
UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
@@ -85,18 +88,19 @@ module Errorgap
|
|
|
85
88
|
env["PATH_INFO"]
|
|
86
89
|
end
|
|
87
90
|
|
|
91
|
+
# Queued, not sent inline: a slow or unreachable errorgap must not add
|
|
92
|
+
# its timeout to a request that is already failing.
|
|
88
93
|
def notify_once(env, exception)
|
|
89
94
|
notified = env["errorgap.notified_exception_ids"] ||= {}
|
|
90
95
|
return if notified[exception.object_id]
|
|
91
96
|
|
|
92
97
|
notified[exception.object_id] = true
|
|
93
|
-
Errorgap.
|
|
98
|
+
Errorgap.notify_once(
|
|
94
99
|
exception,
|
|
95
100
|
context: rack_context(env),
|
|
96
101
|
environment: rack_environment(env),
|
|
97
102
|
session: rack_session(env),
|
|
98
|
-
params: rack_params(env)
|
|
99
|
-
sync: true
|
|
103
|
+
params: rack_params(env)
|
|
100
104
|
)
|
|
101
105
|
end
|
|
102
106
|
|
|
@@ -112,15 +116,54 @@ module Errorgap
|
|
|
112
116
|
{
|
|
113
117
|
method: env["REQUEST_METHOD"],
|
|
114
118
|
path: env["PATH_INFO"],
|
|
115
|
-
query_string: env["QUERY_STRING"],
|
|
119
|
+
query_string: filtered_query(env["QUERY_STRING"]),
|
|
116
120
|
user_agent: env["HTTP_USER_AGENT"],
|
|
117
121
|
remote_addr: env["REMOTE_ADDR"]
|
|
118
122
|
}
|
|
119
123
|
end
|
|
120
124
|
|
|
125
|
+
# `?token=abc` is as sensitive in the query string as it is in params.
|
|
126
|
+
def filtered_query(query)
|
|
127
|
+
return query if query.nil? || query.empty?
|
|
128
|
+
|
|
129
|
+
keys = Array(Errorgap.configuration.filter_keys)
|
|
130
|
+
query.split("&").map do |pair|
|
|
131
|
+
name, value = pair.split("=", 2)
|
|
132
|
+
sensitive = keys.any? do |key|
|
|
133
|
+
key.is_a?(Regexp) ? key.match?(name.to_s) : name.to_s.downcase.include?(key.to_s.downcase)
|
|
134
|
+
end
|
|
135
|
+
sensitive && value ? "#{name}=[FILTERED]" : pair
|
|
136
|
+
end.join("&")
|
|
137
|
+
rescue StandardError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
121
141
|
def rack_session(env)
|
|
122
142
|
session = env["rack.session"]
|
|
123
|
-
session.respond_to?(:to_hash) ? session.to_hash : {}
|
|
143
|
+
data = session.respond_to?(:to_hash) ? session.to_hash : {}
|
|
144
|
+
signed_in_user(env).merge(data)
|
|
145
|
+
rescue StandardError
|
|
146
|
+
{}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The signed-in user (Warden / Devise), unless the app named one itself
|
|
150
|
+
# with `Errorgap.set_user`. Only looks at a user Warden already loaded:
|
|
151
|
+
# it never triggers authentication from inside error handling.
|
|
152
|
+
def signed_in_user(env)
|
|
153
|
+
return {} unless Errorgap.configuration.capture_user
|
|
154
|
+
return {} if Errorgap.current_user
|
|
155
|
+
|
|
156
|
+
warden = env["warden"]
|
|
157
|
+
return {} unless warden.respond_to?(:authenticated?) && warden.respond_to?(:user)
|
|
158
|
+
return {} unless warden.authenticated?
|
|
159
|
+
|
|
160
|
+
user = warden.user
|
|
161
|
+
found = {}
|
|
162
|
+
found["user_id"] = user.id.to_s if user.respond_to?(:id) && user.id
|
|
163
|
+
found["user_email"] = user.email.to_s if user.respond_to?(:email) && user.email
|
|
164
|
+
found
|
|
165
|
+
rescue StandardError
|
|
166
|
+
{}
|
|
124
167
|
end
|
|
125
168
|
|
|
126
169
|
def rack_params(env)
|
|
@@ -23,6 +23,14 @@ module Errorgap
|
|
|
23
23
|
# subscribers no-op unless the middleware starts a per-request
|
|
24
24
|
# collection, which does check the flag.
|
|
25
25
|
SpanCollector.install
|
|
26
|
+
Errorgap::Integrations::NetHTTP.install
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Background jobs: report their errors and time them. Like the span
|
|
30
|
+
# collector these check the configuration when a job runs.
|
|
31
|
+
initializer "errorgap.install_job_integrations" do
|
|
32
|
+
Errorgap::Integrations::ActiveJob.install
|
|
33
|
+
Errorgap::Integrations::Sidekiq.install
|
|
26
34
|
end
|
|
27
35
|
|
|
28
36
|
# After config/initializers, so `auth_events` is readable here.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Errorgap
|
|
4
|
+
# Works out which commit is running when `config.release` is not set, so
|
|
5
|
+
# errors are attributed to a deploy without any setup: the commit variables
|
|
6
|
+
# hosting platforms export, Capistrano's REVISION file, then the checkout's
|
|
7
|
+
# `.git/HEAD`.
|
|
8
|
+
module ReleaseDetector
|
|
9
|
+
ENV_KEYS = %w[
|
|
10
|
+
SOURCE_VERSION HEROKU_SLUG_COMMIT HEROKU_BUILD_COMMIT RENDER_GIT_COMMIT
|
|
11
|
+
RAILWAY_GIT_COMMIT_SHA VERCEL_GIT_COMMIT_SHA KAMAL_VERSION GIT_COMMIT GIT_SHA
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def detect(root_directory, env = ENV)
|
|
16
|
+
ENV_KEYS.each do |key|
|
|
17
|
+
value = env[key].to_s.strip
|
|
18
|
+
return value unless value.empty?
|
|
19
|
+
end
|
|
20
|
+
root = root_directory.to_s
|
|
21
|
+
return nil if root.empty?
|
|
22
|
+
|
|
23
|
+
revision_file(root) || git_head(root)
|
|
24
|
+
rescue StandardError
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def revision_file(root)
|
|
31
|
+
path = File.join(root, "REVISION")
|
|
32
|
+
return nil unless File.file?(path)
|
|
33
|
+
|
|
34
|
+
value = File.read(path, 200).to_s.strip
|
|
35
|
+
value.empty? ? nil : value
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def git_head(root)
|
|
39
|
+
git = File.join(root, ".git")
|
|
40
|
+
# A worktree or submodule has a `.git` file pointing at the real dir.
|
|
41
|
+
if File.file?(git)
|
|
42
|
+
pointer = File.read(git, 1024).to_s[/\Agitdir:\s*(.+)$/, 1]
|
|
43
|
+
return nil unless pointer
|
|
44
|
+
|
|
45
|
+
git = File.expand_path(pointer.strip, root)
|
|
46
|
+
end
|
|
47
|
+
head_path = File.join(git, "HEAD")
|
|
48
|
+
return nil unless File.file?(head_path)
|
|
49
|
+
|
|
50
|
+
head = File.read(head_path, 1024).to_s.strip
|
|
51
|
+
ref = head[/\Aref:\s*(.+)\z/, 1]
|
|
52
|
+
return sha(head) unless ref
|
|
53
|
+
|
|
54
|
+
ref_path = File.join(git, ref)
|
|
55
|
+
return sha(File.read(ref_path, 200).to_s.strip) if File.file?(ref_path)
|
|
56
|
+
|
|
57
|
+
packed = File.join(git, "packed-refs")
|
|
58
|
+
return nil unless File.file?(packed)
|
|
59
|
+
|
|
60
|
+
line = File.foreach(packed).find { |entry| entry.strip.end_with?(" #{ref}") }
|
|
61
|
+
line ? sha(line.split(" ").first) : nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def sha(value)
|
|
65
|
+
value.to_s.match?(/\A\h{7,64}\z/) ? value : nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Errorgap
|
|
4
|
+
# Makes caller-supplied data safe to serialize: masks sensitive keys at any
|
|
5
|
+
# depth, cuts long strings and large or deeply nested collections, replaces
|
|
6
|
+
# invalid UTF-8 and breaks reference cycles. Without it one binary param or
|
|
7
|
+
# a self-referencing hash loses the whole notice.
|
|
8
|
+
class Sanitizer
|
|
9
|
+
FILTERED = "[FILTERED]"
|
|
10
|
+
TRUNCATED = "[TRUNCATED]"
|
|
11
|
+
CIRCULAR = "[CIRCULAR]"
|
|
12
|
+
MAX_STRING = 4096
|
|
13
|
+
MAX_ITEMS = 128
|
|
14
|
+
MAX_DEPTH = 8
|
|
15
|
+
|
|
16
|
+
# `filter_keys` entries are substrings (case-insensitive) or Regexps.
|
|
17
|
+
def initialize(filter_keys = [])
|
|
18
|
+
@filter_keys = Array(filter_keys).map { |key| key.is_a?(Regexp) ? key : key.to_s.downcase }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Truncate only.
|
|
22
|
+
def clean(value)
|
|
23
|
+
walk(value, false, 0, {})
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Truncate and mask sensitive keys.
|
|
27
|
+
def filter(value)
|
|
28
|
+
walk(value, true, 0, {})
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.string(value, limit = MAX_STRING)
|
|
32
|
+
text = value.to_s
|
|
33
|
+
text = text.dup.force_encoding(Encoding::UTF_8) unless text.encoding == Encoding::UTF_8
|
|
34
|
+
text = text.scrub("?") unless text.valid_encoding?
|
|
35
|
+
text.length > limit ? "#{text[0, limit]}#{TRUNCATED}" : text
|
|
36
|
+
rescue StandardError
|
|
37
|
+
"[UNPRINTABLE]"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def walk(value, masking, depth, seen)
|
|
43
|
+
case value
|
|
44
|
+
when Hash
|
|
45
|
+
nested(value, depth, seen) do
|
|
46
|
+
value.first(MAX_ITEMS).each_with_object({}) do |(key, item), result|
|
|
47
|
+
name = self.class.string(key, 256)
|
|
48
|
+
result[name] = masking && sensitive?(name) ? FILTERED : walk(item, masking, depth + 1, seen)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
when Array
|
|
52
|
+
nested(value, depth, seen) do
|
|
53
|
+
value.first(MAX_ITEMS).map { |item| walk(item, masking, depth + 1, seen) }
|
|
54
|
+
end
|
|
55
|
+
when String, Symbol
|
|
56
|
+
self.class.string(value)
|
|
57
|
+
when Integer, NilClass, TrueClass, FalseClass
|
|
58
|
+
value
|
|
59
|
+
when Float
|
|
60
|
+
value.finite? ? value : value.to_s
|
|
61
|
+
else
|
|
62
|
+
if value.respond_to?(:to_hash)
|
|
63
|
+
walk(value.to_hash, masking, depth, seen)
|
|
64
|
+
else
|
|
65
|
+
self.class.string(value)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
rescue StandardError
|
|
69
|
+
"[UNPRINTABLE]"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def nested(value, depth, seen)
|
|
73
|
+
return TRUNCATED if depth >= MAX_DEPTH
|
|
74
|
+
return CIRCULAR if seen[value.object_id]
|
|
75
|
+
|
|
76
|
+
seen[value.object_id] = true
|
|
77
|
+
begin
|
|
78
|
+
yield
|
|
79
|
+
ensure
|
|
80
|
+
seen.delete(value.object_id)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def sensitive?(key)
|
|
85
|
+
lowered = key.downcase
|
|
86
|
+
@filter_keys.any? do |filter|
|
|
87
|
+
filter.is_a?(Regexp) ? filter.match?(key) : lowered.include?(filter)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/errorgap/sign_ins.rb
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
require "net/http"
|
|
5
|
-
require "uri"
|
|
6
3
|
require "time"
|
|
7
4
|
|
|
8
5
|
module Errorgap
|
|
@@ -53,27 +50,13 @@ module Errorgap
|
|
|
53
50
|
if sync || !@configuration.async
|
|
54
51
|
deliver(payload)
|
|
55
52
|
else
|
|
56
|
-
Errorgap.
|
|
53
|
+
Errorgap.delivery.enqueue("logins/web", payload)
|
|
57
54
|
nil
|
|
58
55
|
end
|
|
59
56
|
end
|
|
60
57
|
|
|
61
58
|
def deliver(payload)
|
|
62
|
-
|
|
63
|
-
@configuration.endpoint.end_with?("/") ? @configuration.endpoint : "#{@configuration.endpoint}/",
|
|
64
|
-
"api/projects/#{@configuration.project_slug}/logins/web"
|
|
65
|
-
)
|
|
66
|
-
request = Net::HTTP::Post.new(uri)
|
|
67
|
-
request["Content-Type"] = "application/json"
|
|
68
|
-
request["User-Agent"] = "errorgap-ruby/#{Errorgap::VERSION}"
|
|
69
|
-
request["X-Errorgap-Project-Key"] = @configuration.api_key if present?(@configuration.api_key)
|
|
70
|
-
request.body = JSON.generate(payload)
|
|
71
|
-
|
|
72
|
-
Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
73
|
-
http.request(request)
|
|
74
|
-
end
|
|
75
|
-
rescue StandardError => exception
|
|
76
|
-
@configuration.logger&.warn("[errorgap] sign-in delivery error: #{exception.class}: #{exception.message}")
|
|
59
|
+
Errorgap.delivery.post("logins/web", payload)
|
|
77
60
|
end
|
|
78
61
|
|
|
79
62
|
private
|
data/lib/errorgap/transacter.rb
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
require "net/http"
|
|
5
|
-
require "uri"
|
|
6
|
-
|
|
7
3
|
module Errorgap
|
|
8
4
|
class Transacter
|
|
9
5
|
def initialize(configuration)
|
|
@@ -18,25 +14,11 @@ module Errorgap
|
|
|
18
14
|
return if @configuration.ignored_environment?
|
|
19
15
|
return unless should_sample?
|
|
20
16
|
|
|
21
|
-
Errorgap.
|
|
17
|
+
Errorgap.delivery.enqueue("transactions", transaction.to_h)
|
|
22
18
|
end
|
|
23
19
|
|
|
24
20
|
def deliver(transaction)
|
|
25
|
-
|
|
26
|
-
@configuration.endpoint.end_with?("/") ? @configuration.endpoint : "#{@configuration.endpoint}/",
|
|
27
|
-
"api/projects/#{@configuration.project_slug}/transactions"
|
|
28
|
-
)
|
|
29
|
-
request = Net::HTTP::Post.new(uri)
|
|
30
|
-
request["Content-Type"] = "application/json"
|
|
31
|
-
request["User-Agent"] = "errorgap-ruby/#{Errorgap::VERSION}"
|
|
32
|
-
request["X-Errorgap-Project-Key"] = @configuration.api_key if present?(@configuration.api_key)
|
|
33
|
-
request.body = JSON.generate(transaction.to_h)
|
|
34
|
-
|
|
35
|
-
Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
36
|
-
http.request(request)
|
|
37
|
-
end
|
|
38
|
-
rescue StandardError => exception
|
|
39
|
-
@configuration.logger&.warn("[errorgap] APM delivery error: #{exception.class}: #{exception.message}")
|
|
21
|
+
Errorgap.delivery.post("transactions", transaction.to_h)
|
|
40
22
|
end
|
|
41
23
|
|
|
42
24
|
private
|
|
@@ -48,9 +30,5 @@ module Errorgap
|
|
|
48
30
|
|
|
49
31
|
rand < rate
|
|
50
32
|
end
|
|
51
|
-
|
|
52
|
-
def present?(value)
|
|
53
|
-
!value.nil? && !value.to_s.empty?
|
|
54
|
-
end
|
|
55
33
|
end
|
|
56
34
|
end
|
data/lib/errorgap/version.rb
CHANGED
data/lib/errorgap.rb
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
|
|
5
5
|
require_relative "errorgap/configuration"
|
|
6
|
+
require_relative "errorgap/sanitizer"
|
|
7
|
+
require_relative "errorgap/release_detector"
|
|
8
|
+
require_relative "errorgap/delivery"
|
|
6
9
|
require_relative "errorgap/breadcrumbs"
|
|
7
10
|
require_relative "errorgap/notifier"
|
|
8
11
|
require_relative "errorgap/notice"
|
|
@@ -14,12 +17,16 @@ require_relative "errorgap/span_collector"
|
|
|
14
17
|
require_relative "errorgap/span_recorder"
|
|
15
18
|
require_relative "errorgap/transacter"
|
|
16
19
|
require_relative "errorgap/rack_middleware"
|
|
20
|
+
require_relative "errorgap/integrations/sidekiq"
|
|
21
|
+
require_relative "errorgap/integrations/active_job"
|
|
22
|
+
require_relative "errorgap/integrations/net_http"
|
|
17
23
|
require_relative "errorgap/version"
|
|
18
24
|
|
|
19
25
|
module Errorgap
|
|
20
26
|
class << self
|
|
21
27
|
def configure
|
|
22
28
|
yield(configuration)
|
|
29
|
+
delivery.configure(configuration)
|
|
23
30
|
notifier.configure(configuration)
|
|
24
31
|
transacter.configure(configuration)
|
|
25
32
|
log_delivery.configure(configuration)
|
|
@@ -35,6 +42,11 @@ module Errorgap
|
|
|
35
42
|
@notifier ||= Notifier.new(configuration)
|
|
36
43
|
end
|
|
37
44
|
|
|
45
|
+
# The shared sender behind notices, transactions, logs and sign-ins.
|
|
46
|
+
def delivery
|
|
47
|
+
@delivery ||= Delivery.new(configuration)
|
|
48
|
+
end
|
|
49
|
+
|
|
38
50
|
def transacter
|
|
39
51
|
@transacter ||= Transacter.new(configuration)
|
|
40
52
|
end
|
|
@@ -65,6 +77,7 @@ module Errorgap
|
|
|
65
77
|
if (transaction_id = current_transaction_id) && !context.key?(:transaction_id) && !context.key?("transaction_id")
|
|
66
78
|
context = context.merge(transaction_id: transaction_id)
|
|
67
79
|
end
|
|
80
|
+
session = user_session.merge(session || {}) if current_user
|
|
68
81
|
notifier.notify(
|
|
69
82
|
error,
|
|
70
83
|
context: context,
|
|
@@ -76,6 +89,82 @@ module Errorgap
|
|
|
76
89
|
)
|
|
77
90
|
end
|
|
78
91
|
|
|
92
|
+
REPORTED_IVAR = :@__errorgap_reported
|
|
93
|
+
USER_KEY = :errorgap_user
|
|
94
|
+
|
|
95
|
+
# `notify`, unless this exception object was already reported: lets the
|
|
96
|
+
# Rack middleware, a job integration and the app's own rescue all see the
|
|
97
|
+
# same error without sending it more than once.
|
|
98
|
+
def notify_once(error, **options)
|
|
99
|
+
return nil if reported?(error)
|
|
100
|
+
|
|
101
|
+
mark_reported(error)
|
|
102
|
+
notify(error, **options)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def reported?(error)
|
|
106
|
+
error.instance_variable_defined?(REPORTED_IVAR)
|
|
107
|
+
rescue StandardError
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def mark_reported(error)
|
|
112
|
+
error.instance_variable_set(REPORTED_IVAR, true) unless error.frozen?
|
|
113
|
+
rescue StandardError
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Name the user the current request or job is acting for. Errors reported
|
|
118
|
+
# on this thread until `clear_user` carry it as `session.user_id` /
|
|
119
|
+
# `session.user_email`, which errorgap's user filters read.
|
|
120
|
+
def set_user(id: nil, email: nil)
|
|
121
|
+
user = {}
|
|
122
|
+
user[:id] = id.to_s unless id.nil? || id.to_s.empty?
|
|
123
|
+
user[:email] = email.to_s unless email.nil? || email.to_s.empty?
|
|
124
|
+
Thread.current[USER_KEY] = user.empty? ? nil : user
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def clear_user
|
|
128
|
+
Thread.current[USER_KEY] = nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def current_user
|
|
132
|
+
Thread.current[USER_KEY]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Record a release with errorgap (Deploys): errors are then attributed to
|
|
136
|
+
# the deploy that shipped them. Defaults come from the configuration.
|
|
137
|
+
def track_release(version: nil, sha: nil, branch: nil, environment: nil, sync: true)
|
|
138
|
+
configuration.validate!
|
|
139
|
+
return if configuration.ignored_environment?
|
|
140
|
+
|
|
141
|
+
payload = {
|
|
142
|
+
version: version || configuration.release,
|
|
143
|
+
sha: sha || configuration.release_sha,
|
|
144
|
+
branch: branch || configuration.release_branch,
|
|
145
|
+
environment: environment || configuration.environment
|
|
146
|
+
}.compact
|
|
147
|
+
return unless payload[:version] || payload[:sha]
|
|
148
|
+
|
|
149
|
+
if sync
|
|
150
|
+
delivery.post("releases", payload)
|
|
151
|
+
else
|
|
152
|
+
delivery.enqueue("releases", payload)
|
|
153
|
+
end
|
|
154
|
+
rescue StandardError => exception
|
|
155
|
+
configuration.logger&.warn("[errorgap] release not recorded: #{exception.class}: #{exception.message}")
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# `config.track_release`: the first notice of a process records the
|
|
160
|
+
# release, so an app that never errors never calls errorgap.
|
|
161
|
+
def record_release_once
|
|
162
|
+
return if @release_recorded
|
|
163
|
+
|
|
164
|
+
@release_recorded = true
|
|
165
|
+
track_release(sync: false)
|
|
166
|
+
end
|
|
167
|
+
|
|
79
168
|
# Record a diagnostic breadcrumb attached to subsequent notices.
|
|
80
169
|
def add_breadcrumb(message, category: nil, metadata: nil)
|
|
81
170
|
breadcrumbs.add(message, category: category, metadata: metadata)
|
|
@@ -126,7 +215,7 @@ module Errorgap
|
|
|
126
215
|
if sync || !configuration.async
|
|
127
216
|
transacter.deliver(transaction)
|
|
128
217
|
else
|
|
129
|
-
|
|
218
|
+
delivery.enqueue("transactions", transaction.to_h)
|
|
130
219
|
end
|
|
131
220
|
end
|
|
132
221
|
|
|
@@ -176,25 +265,38 @@ module Errorgap
|
|
|
176
265
|
end
|
|
177
266
|
end
|
|
178
267
|
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
def flush
|
|
268
|
+
# Wait for queued notices, transactions and logs to be sent. Call before
|
|
269
|
+
# process exit; queued payloads are also flushed from an `at_exit` hook.
|
|
270
|
+
def flush(timeout = Delivery::FLUSH_TIMEOUT)
|
|
271
|
+
delivery.flush(timeout)
|
|
182
272
|
threads = thread_mutex.synchronize do
|
|
183
273
|
pending = delivery_threads.dup
|
|
184
274
|
delivery_threads.clear
|
|
185
275
|
pending
|
|
186
276
|
end
|
|
187
|
-
threads.each { |thread| thread.join(
|
|
277
|
+
threads.each { |thread| thread.join(timeout) }
|
|
188
278
|
nil
|
|
189
279
|
end
|
|
190
280
|
|
|
281
|
+
# Kept for callers that run their own delivery threads.
|
|
191
282
|
def register_thread(thread)
|
|
192
|
-
thread_mutex.synchronize
|
|
283
|
+
thread_mutex.synchronize do
|
|
284
|
+
delivery_threads.select!(&:alive?)
|
|
285
|
+
delivery_threads << thread
|
|
286
|
+
end
|
|
193
287
|
thread
|
|
194
288
|
end
|
|
195
289
|
|
|
196
290
|
private
|
|
197
291
|
|
|
292
|
+
def user_session
|
|
293
|
+
user = current_user || {}
|
|
294
|
+
session = {}
|
|
295
|
+
session["user_id"] = user[:id] if user[:id]
|
|
296
|
+
session["user_email"] = user[:email] if user[:email]
|
|
297
|
+
session
|
|
298
|
+
end
|
|
299
|
+
|
|
198
300
|
def sample_apm?
|
|
199
301
|
rate = configuration.apm_sample_rate.to_f
|
|
200
302
|
return true if rate >= 1.0
|