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.
@@ -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,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
@@ -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.register_thread(Thread.new { deliver(payload) })
53
+ Errorgap.delivery.enqueue("logins/web", payload)
57
54
  nil
58
55
  end
59
56
  end
60
57
 
61
58
  def deliver(payload)
62
- uri = URI.join(
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
@@ -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.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
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
- register_thread(Thread.new { transacter.deliver(transaction) })
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
- # Join any in-flight async delivery threads. Call before process exit to
180
- # avoid dropping queued notices/transactions/logs.
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(5) }
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 { delivery_threads << thread }
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