errorgap 0.8.0 → 0.10.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.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Errorgap
4
+ module Integrations
5
+ # Times outbound `Net::HTTP` requests made during a transaction and
6
+ # records each as an `http` span named by method and host. Most Ruby HTTP
7
+ # clients (Faraday's default adapter, HTTParty, RestClient) use Net::HTTP
8
+ # underneath. Requests errorgap makes itself are left out.
9
+ module NetHTTP
10
+ class << self
11
+ def install
12
+ return false unless defined?(::Net::HTTP)
13
+ return true if @installed
14
+
15
+ ::Net::HTTP.prepend(Patch)
16
+ @installed = true
17
+ end
18
+ end
19
+
20
+ module Patch
21
+ def request(req, body = nil, &block)
22
+ # `request` calls itself once when the connection is not started.
23
+ unless started? && Thread.current[SpanCollector::THREAD_KEY] && !Errorgap::Delivery.sending?
24
+ return super
25
+ end
26
+
27
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
28
+ begin
29
+ super
30
+ ensure
31
+ elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
32
+ Errorgap::Integrations::NetHTTP.record(req, address, elapsed_ms)
33
+ end
34
+ end
35
+ end
36
+
37
+ def self.record(request, host, elapsed_ms)
38
+ file, line, = SpanCollector.app_call_site
39
+ SpanCollector.store << Span.new(
40
+ kind: "http",
41
+ fn_name: "#{request.method} #{host}",
42
+ file: file, line: line,
43
+ duration_ms: elapsed_ms.round(3)
44
+ )
45
+ rescue StandardError
46
+ nil
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Errorgap
4
+ module Integrations
5
+ # Rake: report an error that ends a task, with the task's name, then
6
+ # re-raise it. The report is flushed before Rake exits.
7
+ #
8
+ # require "errorgap"
9
+ # Errorgap::Integrations::Rake.install # in the Rakefile
10
+ #
11
+ # In Rails this is installed for you.
12
+ module Rake
13
+ class << self
14
+ def install
15
+ return false unless defined?(::Rake::Task)
16
+ return true if @installed
17
+
18
+ ::Rake::Task.prepend(Patch)
19
+ @installed = true
20
+ end
21
+ end
22
+
23
+ module Patch
24
+ def execute(args = nil)
25
+ super
26
+ rescue SystemExit, Interrupt
27
+ raise
28
+ rescue Exception => exception # rubocop:disable Lint/RescueException
29
+ begin
30
+ Errorgap.notify_once(
31
+ exception,
32
+ context: { component: "rake", action: name.to_s },
33
+ params: { "arguments" => args.respond_to?(:to_a) ? args.to_a : nil }
34
+ )
35
+ Errorgap.flush
36
+ rescue StandardError
37
+ nil
38
+ end
39
+ raise
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Errorgap
4
+ module Integrations
5
+ # Resque: report failed jobs with their class, queue and arguments.
6
+ # Resque's own failure backend (the Failed tab) keeps working alongside.
7
+ #
8
+ # Errorgap::Integrations::Resque.install # after Resque is loaded
9
+ module Resque
10
+ class << self
11
+ def install
12
+ return false unless defined?(::Resque::Failure::Base)
13
+ return true if @installed
14
+
15
+ require "resque/failure/multiple"
16
+ current = ::Resque::Failure.backend
17
+ backends = current == ::Resque::Failure::Multiple ? ::Resque::Failure::Multiple.classes : [current]
18
+ ::Resque::Failure::Multiple.classes = (backends + [backend]).uniq
19
+ ::Resque::Failure.backend = ::Resque::Failure::Multiple
20
+ @installed = true
21
+ end
22
+
23
+ # The failure backend, defined on first use so loading errorgap does
24
+ # not need Resque.
25
+ def backend
26
+ @backend ||= Class.new(::Resque::Failure::Base) do
27
+ def save
28
+ job_class = payload.is_a?(Hash) ? payload["class"].to_s : ""
29
+ Errorgap.notify_once(
30
+ exception,
31
+ context: { component: "resque", action: job_class },
32
+ environment: { queue: queue.to_s },
33
+ params: { "arguments" => payload.is_a?(Hash) ? payload["args"] : nil }
34
+ )
35
+ end
36
+
37
+ def self.count(*)
38
+ 0
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Errorgap
4
+ module Integrations
5
+ # Sidekiq server middleware: every job runs as a `job` transaction and an
6
+ # error it raises is reported with the job's class, queue, id and
7
+ # arguments, then re-raised so Sidekiq's retries work as before.
8
+ #
9
+ # Installed automatically when Sidekiq is loaded before errorgap is
10
+ # configured; otherwise:
11
+ #
12
+ # Sidekiq.configure_server do |config|
13
+ # config.server_middleware { |chain| chain.add Errorgap::Integrations::Sidekiq }
14
+ # end
15
+ class Sidekiq
16
+ class << self
17
+ def install
18
+ return false unless defined?(::Sidekiq) && ::Sidekiq.respond_to?(:configure_server)
19
+ return true if @installed
20
+
21
+ ::Sidekiq.configure_server do |config|
22
+ config.server_middleware { |chain| chain.add Errorgap::Integrations::Sidekiq }
23
+ end
24
+ @installed = true
25
+ end
26
+ end
27
+
28
+ def call(_worker, job, queue, &block)
29
+ # An ActiveJob run by Sidekiq is covered by the ActiveJob integration,
30
+ # which knows the real job class and its arguments.
31
+ return yield if job["wrapped"] && Errorgap::Integrations::ActiveJob.installed?
32
+
33
+ job_class = (job["wrapped"] || job["class"]).to_s
34
+ Errorgap.track_job(job_class, queue: queue.to_s) do
35
+ run(job, job_class, queue, &block)
36
+ end
37
+ ensure
38
+ Errorgap.clear_user
39
+ end
40
+
41
+ private
42
+
43
+ def run(job, job_class, queue)
44
+ yield
45
+ rescue Exception => exception # rubocop:disable Lint/RescueException
46
+ Errorgap.notify_once(
47
+ exception,
48
+ context: { component: "sidekiq", action: job_class },
49
+ environment: { queue: queue.to_s, job_id: job["jid"], retry_count: job["retry_count"] }.compact,
50
+ params: { "arguments" => job["args"] }
51
+ )
52
+ raise
53
+ end
54
+ end
55
+ end
56
+ end
@@ -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
@@ -24,6 +21,7 @@ module Errorgap
24
21
  def log(message, level: "info", source: nil, environment: nil, occurred_at: nil, sync: false)
25
22
  return unless @configuration.logs_enabled
26
23
  return if @configuration.ignored_environment?
24
+ return unless Errorgap.remote_settings.enabled?(:logs)
27
25
 
28
26
  normalized = self.class.normalize_level(level)
29
27
  return if self.class.rank(normalized) < self.class.rank(self.class.normalize_level(@configuration.minimum_log_level))
@@ -39,27 +37,13 @@ module Errorgap
39
37
  if sync || !@configuration.async
40
38
  deliver(payload)
41
39
  else
42
- Errorgap.register_thread(Thread.new { deliver(payload) })
40
+ Errorgap.delivery.enqueue("logs", payload)
43
41
  nil
44
42
  end
45
43
  end
46
44
 
47
45
  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}")
46
+ Errorgap.delivery.post("logs", payload)
63
47
  end
64
48
 
65
49
  def self.normalize_level(level)
@@ -71,11 +55,5 @@ module Errorgap
71
55
  def self.rank(level)
72
56
  LEVELS.index(level) || LEVELS.index("info")
73
57
  end
74
-
75
- private
76
-
77
- def present?(value)
78
- !value.nil? && !value.to_s.empty?
79
- end
80
58
  end
81
59
  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
@@ -23,6 +19,9 @@ module Errorgap
23
19
  def notify(error, context: {}, environment: {}, session: {}, params: {}, breadcrumbs: [], sync: false)
24
20
  @configuration.validate!
25
21
  return Response.new(status: 202, body: "ignored environment") if @configuration.ignored_environment?
22
+ unless Errorgap.remote_settings.enabled?(:errors)
23
+ return Response.new(status: 202, body: "errors are switched off for this project")
24
+ end
26
25
 
27
26
  notice = Notice.from_exception(
28
27
  error,
@@ -33,11 +32,15 @@ module Errorgap
33
32
  params: params,
34
33
  breadcrumbs: breadcrumbs
35
34
  )
35
+ payload = run_hooks(notice.to_h)
36
+ return Response.new(status: 202, body: "dropped by before_notify") unless payload
37
+
38
+ Errorgap.record_release_once if @configuration.track_release
36
39
 
37
40
  if sync || !@configuration.async
38
- deliver(notice)
41
+ deliver(payload)
39
42
  else
40
- Errorgap.register_thread(Thread.new { deliver(notice) })
43
+ Errorgap.delivery.enqueue("notices", payload)
41
44
  Response.new(status: 202, body: "queued")
42
45
  end
43
46
  rescue StandardError => exception
@@ -45,18 +48,11 @@ module Errorgap
45
48
  Response.new(error: exception)
46
49
  end
47
50
 
51
+ # `notice` is a Notice or an already built payload Hash.
48
52
  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)
53
+ payload = notice.respond_to?(:to_h) && !notice.is_a?(Hash) ? notice.to_h : notice
54
+ result = Errorgap.delivery.post("notices", payload)
55
+ Response.new(status: result.status, body: result.body, error: result.error)
60
56
  rescue StandardError => exception
61
57
  log(exception)
62
58
  Response.new(error: exception)
@@ -64,12 +60,25 @@ module Errorgap
64
60
 
65
61
  private
66
62
 
67
- def log(exception)
68
- @configuration.logger&.warn("[errorgap] #{exception.class}: #{exception.message}")
63
+ # A hook that raises is logged and skipped: a bug in a filter must not
64
+ # lose the error it was filtering.
65
+ def run_hooks(payload)
66
+ @configuration.before_notify_hooks.each do |hook|
67
+ begin
68
+ result = hook.call(payload)
69
+ rescue StandardError => exception
70
+ log(exception)
71
+ next
72
+ end
73
+ return nil if result == false
74
+
75
+ payload = result if result.is_a?(Hash) && result.key?(:errors)
76
+ end
77
+ payload
69
78
  end
70
79
 
71
- def present?(value)
72
- !value.nil? && !value.to_s.empty?
80
+ def log(exception)
81
+ @configuration.logger&.warn("[errorgap] #{exception.class}: #{exception.message}")
73
82
  end
74
83
  end
75
84
  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,21 @@ 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
+ Errorgap::Integrations::Resque.install
35
+ Errorgap::Integrations::DelayedJob.install
36
+ end
37
+
38
+ # `rails some:task` / `rake some:task` errors.
39
+ rake_tasks do
40
+ Errorgap::Integrations::Rake.install
26
41
  end
27
42
 
28
43
  # 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