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.
@@ -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.register_thread(Thread.new { deliver(payload) })
39
+ Errorgap.delivery.enqueue("logs", payload)
43
40
  nil
44
41
  end
45
42
  end
46
43
 
47
44
  def deliver(payload)
48
- uri = URI.join(
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
@@ -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.to_s,
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(stringify_hash(@context)),
49
- environment: stringify_hash(@environment),
50
- session: stringify_hash(@session),
51
- params: filter_hash(@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.to_s }
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 filter_hash(hash)
177
- stringify_hash(hash).each_with_object({}) do |(key, value), filtered|
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
@@ -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(notice)
38
+ deliver(payload)
39
39
  else
40
- Errorgap.register_thread(Thread.new { deliver(notice) })
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
- uri = URI.join(@configuration.endpoint.end_with?("/") ? @configuration.endpoint : "#{@configuration.endpoint}/", "api/projects/#{@configuration.project_slug}/notices")
50
- request = Net::HTTP::Post.new(uri)
51
- request["Content-Type"] = "application/json"
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
- def log(exception)
68
- @configuration.logger&.warn("[errorgap] #{exception.class}: #{exception.message}")
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 present?(value)
72
- !value.nil? && !value.to_s.empty?
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.notify(
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
@@ -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.register_thread(Thread.new { deliver(transaction) })
17
+ Errorgap.delivery.enqueue("transactions", transaction.to_h)
22
18
  end
23
19
 
24
20
  def deliver(transaction)
25
- uri = URI.join(
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Errorgap
4
- VERSION = "0.7.0"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -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