errorgap 0.7.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 +61 -0
- data/README.md +79 -1
- data/exe/errorgap +17 -1
- data/lib/errorgap/configuration.rb +55 -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 +13 -0
- data/lib/errorgap/release_detector.rb +69 -0
- data/lib/errorgap/sanitizer.rb +91 -0
- data/lib/errorgap/sign_ins.rb +68 -0
- data/lib/errorgap/transacter.rb +2 -24
- data/lib/errorgap/version.rb +1 -1
- data/lib/errorgap/warden_hooks.rb +88 -0
- data/lib/errorgap.rb +124 -6
- metadata +10 -2
|
@@ -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
|
|
@@ -39,27 +36,13 @@ module Errorgap
|
|
|
39
36
|
if sync || !@configuration.async
|
|
40
37
|
deliver(payload)
|
|
41
38
|
else
|
|
42
|
-
Errorgap.
|
|
39
|
+
Errorgap.delivery.enqueue("logs", payload)
|
|
43
40
|
nil
|
|
44
41
|
end
|
|
45
42
|
end
|
|
46
43
|
|
|
47
44
|
def deliver(payload)
|
|
48
|
-
|
|
49
|
-
@configuration.endpoint.end_with?("/") ? @configuration.endpoint : "#{@configuration.endpoint}/",
|
|
50
|
-
"api/projects/#{@configuration.project_slug}/logs"
|
|
51
|
-
)
|
|
52
|
-
request = Net::HTTP::Post.new(uri)
|
|
53
|
-
request["Content-Type"] = "application/json"
|
|
54
|
-
request["User-Agent"] = "errorgap-ruby/#{Errorgap::VERSION}"
|
|
55
|
-
request["X-Errorgap-Project-Key"] = @configuration.api_key if present?(@configuration.api_key)
|
|
56
|
-
request.body = JSON.generate(payload)
|
|
57
|
-
|
|
58
|
-
Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
59
|
-
http.request(request)
|
|
60
|
-
end
|
|
61
|
-
rescue StandardError => exception
|
|
62
|
-
@configuration.logger&.warn("[errorgap] log delivery error: #{exception.class}: #{exception.message}")
|
|
45
|
+
Errorgap.delivery.post("logs", payload)
|
|
63
46
|
end
|
|
64
47
|
|
|
65
48
|
def self.normalize_level(level)
|
|
@@ -71,11 +54,5 @@ module Errorgap
|
|
|
71
54
|
def self.rank(level)
|
|
72
55
|
LEVELS.index(level) || LEVELS.index("info")
|
|
73
56
|
end
|
|
74
|
-
|
|
75
|
-
private
|
|
76
|
-
|
|
77
|
-
def present?(value)
|
|
78
|
-
!value.nil? && !value.to_s.empty?
|
|
79
|
-
end
|
|
80
57
|
end
|
|
81
58
|
end
|
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,19 @@ 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
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# After config/initializers, so `auth_events` is readable here.
|
|
37
|
+
config.after_initialize do
|
|
38
|
+
Errorgap::WardenHooks.install if Errorgap.configuration.auth_events
|
|
26
39
|
end
|
|
27
40
|
|
|
28
41
|
generators do
|
|
@@ -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
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Errorgap
|
|
6
|
+
# Sign-ins to this app, shown beside SSH logins in Security › Logins.
|
|
7
|
+
# Opt in with `config.auth_events = true`. Only who, from where and the
|
|
8
|
+
# result are sent: never passwords, tokens or session ids.
|
|
9
|
+
class SignIns
|
|
10
|
+
OUTCOMES = %w[success failure password_reset mfa_failure locked].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(configuration)
|
|
13
|
+
configure(configuration)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def configure(configuration)
|
|
17
|
+
@configuration = configuration
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# `request` (Rack or ActionDispatch) fills in the IP, user agent and path.
|
|
21
|
+
def record(outcome, user: nil, request: nil, ip: nil, user_agent: nil, path: nil, method: nil,
|
|
22
|
+
occurred_at: nil, sync: false)
|
|
23
|
+
return unless @configuration.auth_events
|
|
24
|
+
return if @configuration.ignored_environment?
|
|
25
|
+
|
|
26
|
+
outcome = outcome.to_s
|
|
27
|
+
unless OUTCOMES.include?(outcome)
|
|
28
|
+
@configuration.logger&.warn("[errorgap] unknown sign-in outcome #{outcome.inspect}")
|
|
29
|
+
return
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if request
|
|
33
|
+
ip ||= request.respond_to?(:remote_ip) ? request.remote_ip : request.ip
|
|
34
|
+
user_agent ||= request.user_agent
|
|
35
|
+
path ||= "#{request.request_method} #{request.path}"
|
|
36
|
+
end
|
|
37
|
+
event = { occurred_at: (occurred_at || Time.now).utc.iso8601(3), outcome: outcome }
|
|
38
|
+
event[:user] = user.to_s if present?(user)
|
|
39
|
+
event[:ip] = ip.to_s if present?(ip)
|
|
40
|
+
event[:user_agent] = user_agent.to_s[0, 512] if present?(user_agent)
|
|
41
|
+
event[:path] = path.to_s[0, 200] if present?(path)
|
|
42
|
+
event[:method] = method.to_s if present?(method)
|
|
43
|
+
payload = {
|
|
44
|
+
app: @configuration.app_name || @configuration.project_slug,
|
|
45
|
+
environment: @configuration.environment,
|
|
46
|
+
sdk: "errorgap-ruby #{Errorgap::VERSION}",
|
|
47
|
+
events: [event]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if sync || !@configuration.async
|
|
51
|
+
deliver(payload)
|
|
52
|
+
else
|
|
53
|
+
Errorgap.delivery.enqueue("logins/web", payload)
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def deliver(payload)
|
|
59
|
+
Errorgap.delivery.post("logins/web", payload)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def present?(value)
|
|
65
|
+
!value.nil? && !value.to_s.strip.empty?
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Errorgap
|
|
4
|
+
# Devise and other Warden apps: report sign-ins without touching the
|
|
5
|
+
# controllers. The Rails railtie installs these when `auth_events` is on;
|
|
6
|
+
# elsewhere call `Errorgap::WardenHooks.install` after configuring.
|
|
7
|
+
module WardenHooks
|
|
8
|
+
# Failures that are not a sign-in attempt: visiting a protected page
|
|
9
|
+
# signed out, an expired session, an account that is not confirmed.
|
|
10
|
+
NOT_ATTEMPTS = %i[unauthenticated timeout inactive unconfirmed].freeze
|
|
11
|
+
# Form fields that hold the name being signed in with.
|
|
12
|
+
USER_PARAMS = %w[email login username].freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def install
|
|
16
|
+
return false unless defined?(::Warden::Manager)
|
|
17
|
+
return true if @installed
|
|
18
|
+
|
|
19
|
+
::Warden::Manager.after_authentication do |user, auth, opts|
|
|
20
|
+
Errorgap::WardenHooks.on_success(user, auth, opts)
|
|
21
|
+
end
|
|
22
|
+
::Warden::Manager.before_failure do |env, opts|
|
|
23
|
+
Errorgap::WardenHooks.on_failure(env, opts)
|
|
24
|
+
end
|
|
25
|
+
@installed = true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def on_success(user, auth, _opts = {})
|
|
29
|
+
# A remember-me cookie signing the user back in is not a new sign-in.
|
|
30
|
+
return if auth.respond_to?(:winning_strategy) && auth.winning_strategy.respond_to?(:key) &&
|
|
31
|
+
auth.winning_strategy.key == :rememberable
|
|
32
|
+
|
|
33
|
+
Errorgap.sign_in("success", user: identify(user), request: request_for(auth.env))
|
|
34
|
+
rescue StandardError => exception
|
|
35
|
+
Errorgap.configuration.logger&.warn("[errorgap] sign-in hook error: #{exception.class}: #{exception.message}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def on_failure(env, opts = {})
|
|
39
|
+
message = opts[:message]&.to_sym
|
|
40
|
+
return if NOT_ATTEMPTS.include?(message)
|
|
41
|
+
|
|
42
|
+
request = request_for(env)
|
|
43
|
+
return unless request&.post?
|
|
44
|
+
|
|
45
|
+
outcome = message == :locked ? "locked" : "failure"
|
|
46
|
+
# Warden points PATH_INFO at the failure app before this runs; the
|
|
47
|
+
# form's path is in `attempted_path` (query string dropped).
|
|
48
|
+
path = opts[:attempted_path] ? "#{request.request_method} #{opts[:attempted_path].to_s.split('?').first}" : nil
|
|
49
|
+
Errorgap.sign_in(outcome, user: attempted_user(request, opts[:scope]), request: request, path: path)
|
|
50
|
+
rescue StandardError => exception
|
|
51
|
+
Errorgap.configuration.logger&.warn("[errorgap] sign-in hook error: #{exception.class}: #{exception.message}")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# How a signed-in user is named: `config.auth_user` (a method name or a
|
|
55
|
+
# proc), else email, username, login, then id.
|
|
56
|
+
def identify(user)
|
|
57
|
+
pick = Errorgap.configuration.auth_user
|
|
58
|
+
return pick.call(user) if pick.respond_to?(:call)
|
|
59
|
+
return user.public_send(pick) if pick && user.respond_to?(pick)
|
|
60
|
+
|
|
61
|
+
%i[email username login id].each do |name|
|
|
62
|
+
return user.public_send(name) if user.respond_to?(name)
|
|
63
|
+
end
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def attempted_user(request, scope)
|
|
70
|
+
params = request.params
|
|
71
|
+
fields = scope && params[scope.to_s].is_a?(Hash) ? params[scope.to_s] : params
|
|
72
|
+
USER_PARAMS.each do |name|
|
|
73
|
+
value = fields[name]
|
|
74
|
+
return value if value.is_a?(String) && !value.strip.empty?
|
|
75
|
+
end
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def request_for(env)
|
|
80
|
+
if defined?(::ActionDispatch::Request)
|
|
81
|
+
::ActionDispatch::Request.new(env)
|
|
82
|
+
elsif defined?(::Rack::Request)
|
|
83
|
+
::Rack::Request.new(env)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|