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,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Errorgap
8
+ # The project's remote settings: what errorgap wants this app to send.
9
+ # Someone can turn errors, performance data or logs off for a project in
10
+ # errorgap (Project settings › What SDKs send) and running apps follow
11
+ # within a poll interval, with no deploy.
12
+ #
13
+ # Read from `GET /api/projects/:slug/config` on a background thread, never
14
+ # on the caller's. Until the first answer, and whenever errorgap cannot be
15
+ # reached, everything stays on.
16
+ class RemoteSettings
17
+ DEFAULT_INTERVAL = 600
18
+ MIN_INTERVAL = 60
19
+ KINDS = { errors: "errors_enabled", apm: "apm_enabled", logs: "logs_enabled" }.freeze
20
+
21
+ def initialize(configuration)
22
+ @configuration = configuration
23
+ @mutex = Mutex.new
24
+ @settings = {}
25
+ @interval = DEFAULT_INTERVAL
26
+ @thread = nil
27
+ @pid = nil
28
+ end
29
+
30
+ def configure(configuration)
31
+ @configuration = configuration
32
+ end
33
+
34
+ # False only when errorgap has said to stop sending this kind of data.
35
+ def enabled?(kind)
36
+ return true unless @configuration.remote_settings
37
+
38
+ ensure_polling
39
+ @settings[KINDS.fetch(kind)] != false
40
+ end
41
+
42
+ # Fetch now, on this thread. Returns the settings, or nil when they
43
+ # could not be read.
44
+ def refresh
45
+ uri = URI.join(base_url, "api/projects/#{@configuration.project_slug}/config")
46
+ request = Net::HTTP::Get.new(uri)
47
+ request["User-Agent"] = "errorgap-ruby/#{Errorgap::VERSION}"
48
+ request["X-Errorgap-Project-Key"] = @configuration.api_key.to_s unless @configuration.api_key.to_s.empty?
49
+ timeout = @configuration.timeout.to_f.positive? ? @configuration.timeout.to_f : 5
50
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https",
51
+ open_timeout: timeout, read_timeout: timeout) do |http|
52
+ http.request(request)
53
+ end
54
+ return nil unless response.code.to_i == 200
55
+
56
+ apply(JSON.parse(response.body))
57
+ rescue StandardError
58
+ nil
59
+ end
60
+
61
+ private
62
+
63
+ def apply(body)
64
+ return nil unless body.is_a?(Hash)
65
+
66
+ @settings = body.slice(*KINDS.values)
67
+ seconds = body["poll_interval_seconds"].to_i
68
+ @interval = seconds >= MIN_INTERVAL ? seconds : DEFAULT_INTERVAL
69
+ @settings
70
+ end
71
+
72
+ # A forked child (Puma workers, Sidekiq) has the settings but not the
73
+ # thread: start its own.
74
+ def ensure_polling
75
+ return if @pid == Process.pid && @thread&.alive?
76
+ return if @configuration.project_slug.to_s.strip.empty?
77
+
78
+ @mutex.synchronize do
79
+ return if @pid == Process.pid && @thread&.alive?
80
+
81
+ @pid = Process.pid
82
+ @thread = Thread.new do
83
+ loop do
84
+ Thread.current[:errorgap_sending] = true
85
+ refresh
86
+ sleep(@interval)
87
+ end
88
+ end
89
+ @thread.name = "errorgap-settings" if @thread.respond_to?(:name=)
90
+ @thread.report_on_exception = false if @thread.respond_to?(:report_on_exception=)
91
+ end
92
+ end
93
+
94
+ def base_url
95
+ endpoint = @configuration.endpoint.to_s
96
+ endpoint.end_with?("/") ? endpoint : "#{endpoint}/"
97
+ end
98
+ end
99
+ 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)
@@ -16,27 +12,14 @@ module Errorgap
16
12
 
17
13
  def deliver_async(transaction)
18
14
  return if @configuration.ignored_environment?
15
+ return unless Errorgap.remote_settings.enabled?(:apm)
19
16
  return unless should_sample?
20
17
 
21
- Errorgap.register_thread(Thread.new { deliver(transaction) })
18
+ Errorgap.delivery.enqueue("transactions", transaction.to_h)
22
19
  end
23
20
 
24
21
  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}")
22
+ Errorgap.delivery.post("transactions", transaction.to_h)
40
23
  end
41
24
 
42
25
  private
@@ -48,9 +31,5 @@ module Errorgap
48
31
 
49
32
  rand < rate
50
33
  end
51
-
52
- def present?(value)
53
- !value.nil? && !value.to_s.empty?
54
- end
55
34
  end
56
35
  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.10.0"
5
5
  end
data/lib/errorgap.rb CHANGED
@@ -3,6 +3,10 @@
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"
9
+ require_relative "errorgap/remote_settings"
6
10
  require_relative "errorgap/breadcrumbs"
7
11
  require_relative "errorgap/notifier"
8
12
  require_relative "errorgap/notice"
@@ -14,12 +18,20 @@ require_relative "errorgap/span_collector"
14
18
  require_relative "errorgap/span_recorder"
15
19
  require_relative "errorgap/transacter"
16
20
  require_relative "errorgap/rack_middleware"
21
+ require_relative "errorgap/integrations/sidekiq"
22
+ require_relative "errorgap/integrations/active_job"
23
+ require_relative "errorgap/integrations/net_http"
24
+ require_relative "errorgap/integrations/resque"
25
+ require_relative "errorgap/integrations/delayed_job"
26
+ require_relative "errorgap/integrations/rake"
17
27
  require_relative "errorgap/version"
18
28
 
19
29
  module Errorgap
20
30
  class << self
21
31
  def configure
22
32
  yield(configuration)
33
+ delivery.configure(configuration)
34
+ remote_settings.configure(configuration)
23
35
  notifier.configure(configuration)
24
36
  transacter.configure(configuration)
25
37
  log_delivery.configure(configuration)
@@ -35,6 +47,16 @@ module Errorgap
35
47
  @notifier ||= Notifier.new(configuration)
36
48
  end
37
49
 
50
+ # What errorgap wants this app to send (the project's remote settings).
51
+ def remote_settings
52
+ @remote_settings ||= RemoteSettings.new(configuration)
53
+ end
54
+
55
+ # The shared sender behind notices, transactions, logs and sign-ins.
56
+ def delivery
57
+ @delivery ||= Delivery.new(configuration)
58
+ end
59
+
38
60
  def transacter
39
61
  @transacter ||= Transacter.new(configuration)
40
62
  end
@@ -65,6 +87,7 @@ module Errorgap
65
87
  if (transaction_id = current_transaction_id) && !context.key?(:transaction_id) && !context.key?("transaction_id")
66
88
  context = context.merge(transaction_id: transaction_id)
67
89
  end
90
+ session = user_session.merge(session || {}) if current_user
68
91
  notifier.notify(
69
92
  error,
70
93
  context: context,
@@ -76,6 +99,82 @@ module Errorgap
76
99
  )
77
100
  end
78
101
 
102
+ REPORTED_IVAR = :@__errorgap_reported
103
+ USER_KEY = :errorgap_user
104
+
105
+ # `notify`, unless this exception object was already reported: lets the
106
+ # Rack middleware, a job integration and the app's own rescue all see the
107
+ # same error without sending it more than once.
108
+ def notify_once(error, **options)
109
+ return nil if reported?(error)
110
+
111
+ mark_reported(error)
112
+ notify(error, **options)
113
+ end
114
+
115
+ def reported?(error)
116
+ error.instance_variable_defined?(REPORTED_IVAR)
117
+ rescue StandardError
118
+ false
119
+ end
120
+
121
+ def mark_reported(error)
122
+ error.instance_variable_set(REPORTED_IVAR, true) unless error.frozen?
123
+ rescue StandardError
124
+ nil
125
+ end
126
+
127
+ # Name the user the current request or job is acting for. Errors reported
128
+ # on this thread until `clear_user` carry it as `session.user_id` /
129
+ # `session.user_email`, which errorgap's user filters read.
130
+ def set_user(id: nil, email: nil)
131
+ user = {}
132
+ user[:id] = id.to_s unless id.nil? || id.to_s.empty?
133
+ user[:email] = email.to_s unless email.nil? || email.to_s.empty?
134
+ Thread.current[USER_KEY] = user.empty? ? nil : user
135
+ end
136
+
137
+ def clear_user
138
+ Thread.current[USER_KEY] = nil
139
+ end
140
+
141
+ def current_user
142
+ Thread.current[USER_KEY]
143
+ end
144
+
145
+ # Record a release with errorgap (Deploys): errors are then attributed to
146
+ # the deploy that shipped them. Defaults come from the configuration.
147
+ def track_release(version: nil, sha: nil, branch: nil, environment: nil, sync: true)
148
+ configuration.validate!
149
+ return if configuration.ignored_environment?
150
+
151
+ payload = {
152
+ version: version || configuration.release,
153
+ sha: sha || configuration.release_sha,
154
+ branch: branch || configuration.release_branch,
155
+ environment: environment || configuration.environment
156
+ }.compact
157
+ return unless payload[:version] || payload[:sha]
158
+
159
+ if sync
160
+ delivery.post("releases", payload)
161
+ else
162
+ delivery.enqueue("releases", payload)
163
+ end
164
+ rescue StandardError => exception
165
+ configuration.logger&.warn("[errorgap] release not recorded: #{exception.class}: #{exception.message}")
166
+ nil
167
+ end
168
+
169
+ # `config.track_release`: the first notice of a process records the
170
+ # release, so an app that never errors never calls errorgap.
171
+ def record_release_once
172
+ return if @release_recorded
173
+
174
+ @release_recorded = true
175
+ track_release(sync: false)
176
+ end
177
+
79
178
  # Record a diagnostic breadcrumb attached to subsequent notices.
80
179
  def add_breadcrumb(message, category: nil, metadata: nil)
81
180
  breadcrumbs.add(message, category: category, metadata: metadata)
@@ -121,12 +220,13 @@ module Errorgap
121
220
  def notify_transaction(transaction, sync: false)
122
221
  return unless configuration.apm_enabled
123
222
  return if configuration.ignored_environment?
223
+ return unless remote_settings.enabled?(:apm)
124
224
  return unless sample_apm?
125
225
 
126
226
  if sync || !configuration.async
127
227
  transacter.deliver(transaction)
128
228
  else
129
- register_thread(Thread.new { transacter.deliver(transaction) })
229
+ delivery.enqueue("transactions", transaction.to_h)
130
230
  end
131
231
  end
132
232
 
@@ -176,25 +276,38 @@ module Errorgap
176
276
  end
177
277
  end
178
278
 
179
- # Join any in-flight async delivery threads. Call before process exit to
180
- # avoid dropping queued notices/transactions/logs.
181
- def flush
279
+ # Wait for queued notices, transactions and logs to be sent. Call before
280
+ # process exit; queued payloads are also flushed from an `at_exit` hook.
281
+ def flush(timeout = Delivery::FLUSH_TIMEOUT)
282
+ delivery.flush(timeout)
182
283
  threads = thread_mutex.synchronize do
183
284
  pending = delivery_threads.dup
184
285
  delivery_threads.clear
185
286
  pending
186
287
  end
187
- threads.each { |thread| thread.join(5) }
288
+ threads.each { |thread| thread.join(timeout) }
188
289
  nil
189
290
  end
190
291
 
292
+ # Kept for callers that run their own delivery threads.
191
293
  def register_thread(thread)
192
- thread_mutex.synchronize { delivery_threads << thread }
294
+ thread_mutex.synchronize do
295
+ delivery_threads.select!(&:alive?)
296
+ delivery_threads << thread
297
+ end
193
298
  thread
194
299
  end
195
300
 
196
301
  private
197
302
 
303
+ def user_session
304
+ user = current_user || {}
305
+ session = {}
306
+ session["user_id"] = user[:id] if user[:id]
307
+ session["user_email"] = user[:email] if user[:email]
308
+ session
309
+ end
310
+
198
311
  def sample_apm?
199
312
  rate = configuration.apm_sample_rate.to_f
200
313
  return true if rate >= 1.0
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: errorgap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Errorgap
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-06 00:00:00.000000000 Z
11
+ date: 2026-10-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -67,11 +67,21 @@ files:
67
67
  - lib/errorgap.rb
68
68
  - lib/errorgap/breadcrumbs.rb
69
69
  - lib/errorgap/configuration.rb
70
+ - lib/errorgap/delivery.rb
71
+ - lib/errorgap/integrations/active_job.rb
72
+ - lib/errorgap/integrations/delayed_job.rb
73
+ - lib/errorgap/integrations/net_http.rb
74
+ - lib/errorgap/integrations/rake.rb
75
+ - lib/errorgap/integrations/resque.rb
76
+ - lib/errorgap/integrations/sidekiq.rb
70
77
  - lib/errorgap/log_delivery.rb
71
78
  - lib/errorgap/notice.rb
72
79
  - lib/errorgap/notifier.rb
73
80
  - lib/errorgap/rack_middleware.rb
74
81
  - lib/errorgap/rails/railtie.rb
82
+ - lib/errorgap/release_detector.rb
83
+ - lib/errorgap/remote_settings.rb
84
+ - lib/errorgap/sanitizer.rb
75
85
  - lib/errorgap/sign_ins.rb
76
86
  - lib/errorgap/span_collector.rb
77
87
  - lib/errorgap/span_recorder.rb