errsight 0.2.2
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 +7 -0
- data/CHANGELOG.md +163 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/errsight.gemspec +26 -0
- data/lib/errsight/backtrace.rb +117 -0
- data/lib/errsight/capture_middleware.rb +95 -0
- data/lib/errsight/client.rb +241 -0
- data/lib/errsight/configuration.rb +57 -0
- data/lib/errsight/hub.rb +53 -0
- data/lib/errsight/integrations/active_job.rb +175 -0
- data/lib/errsight/integrations/active_record.rb +94 -0
- data/lib/errsight/integrations/rails_error_reporter.rb +107 -0
- data/lib/errsight/logger.rb +85 -0
- data/lib/errsight/middleware.rb +16 -0
- data/lib/errsight/railtie.rb +198 -0
- data/lib/errsight/scope.rb +166 -0
- data/lib/errsight/sidekiq.rb +248 -0
- data/lib/errsight/source_context.rb +107 -0
- data/lib/errsight/version.rb +3 -0
- data/lib/errsight.rb +193 -0
- metadata +79 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
module Errsight
|
|
2
|
+
# Sidekiq integration. Three pieces:
|
|
3
|
+
#
|
|
4
|
+
# 1. ClientMiddleware — runs on enqueue, snapshots the current scope
|
|
5
|
+
# (user/tags/breadcrumbs) into the job payload so the worker side can
|
|
6
|
+
# restore it when the job runs.
|
|
7
|
+
# 2. ServerMiddleware — runs around `perform`, rehydrates the snapshot,
|
|
8
|
+
# reports any exception with structured Sidekiq context, and re-raises
|
|
9
|
+
# so Sidekiq's retry/death machinery still fires.
|
|
10
|
+
# 3. error_handlers entry — Sidekiq's safety net for exceptions raised
|
|
11
|
+
# *outside* the middleware chain (fetch errors, middleware itself
|
|
12
|
+
# raising). Dedup-aware so middleware-captured exceptions aren't
|
|
13
|
+
# reported twice.
|
|
14
|
+
#
|
|
15
|
+
# We do NOT enqueue Errsight's HTTP delivery as Sidekiq jobs. Delivery
|
|
16
|
+
# stays on the SDK's own background flush thread so error reporting still
|
|
17
|
+
# works when the customer's Sidekiq is broken (Redis down, workers stuck).
|
|
18
|
+
module Sidekiq
|
|
19
|
+
# Cap propagated breadcrumbs so we don't bloat job payloads in Redis. A
|
|
20
|
+
# job carrying 50 crumbs × 200 bytes × 1M jobs/day = 10 GB/day, which is
|
|
21
|
+
# not a cost we should silently impose on the customer's Redis bill.
|
|
22
|
+
MAX_PROPAGATED_BREADCRUMBS = 20
|
|
23
|
+
|
|
24
|
+
# Hard cap on per-arg serialized size. Jobs occasionally carry large
|
|
25
|
+
# payloads (file blobs, base64 images) and we'd rather drop them than
|
|
26
|
+
# blow past the API's 512KB ingestion limit.
|
|
27
|
+
MAX_ARG_BYTES = 4_096
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# Idempotent. Safe to call multiple times: the @configured flag and
|
|
31
|
+
# chain.exists? guards prevent double-registration if the host requires
|
|
32
|
+
# us once via Bundler and once via the Railtie.
|
|
33
|
+
def configure_integration!
|
|
34
|
+
return unless defined?(::Sidekiq)
|
|
35
|
+
return if @configured
|
|
36
|
+
@configured = true
|
|
37
|
+
|
|
38
|
+
::Sidekiq.configure_server do |config|
|
|
39
|
+
config.server_middleware do |chain|
|
|
40
|
+
# Prepend so we wrap *all* other server middleware — exceptions
|
|
41
|
+
# raised by other middleware are caught here too.
|
|
42
|
+
chain.prepend(ServerMiddleware) unless chain.exists?(ServerMiddleware)
|
|
43
|
+
end
|
|
44
|
+
config.client_middleware do |chain|
|
|
45
|
+
chain.add(ClientMiddleware) unless chain.exists?(ClientMiddleware)
|
|
46
|
+
end
|
|
47
|
+
register_error_handler(config)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
::Sidekiq.configure_client do |config|
|
|
51
|
+
config.client_middleware do |chain|
|
|
52
|
+
chain.add(ClientMiddleware) unless chain.exists?(ClientMiddleware)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Sidekiq 7+ exposes per-config error_handlers; pre-7 used the global
|
|
60
|
+
# Sidekiq.error_handlers array. Support both so users on older Sidekiq
|
|
61
|
+
# don't have to wire the safety net manually.
|
|
62
|
+
def register_error_handler(config)
|
|
63
|
+
# The 3rd arg (config) was added in Sidekiq 7. Default it so the
|
|
64
|
+
# same proc works under Sidekiq 6.
|
|
65
|
+
handler = proc do |exception, ctx, _config = nil|
|
|
66
|
+
next unless exception.is_a?(Exception)
|
|
67
|
+
# If the server middleware already reported this exception, skip —
|
|
68
|
+
# otherwise a single failed job lands in the issue list twice.
|
|
69
|
+
seen = Thread.current[:errsight_captured_exceptions] ||= []
|
|
70
|
+
next if seen.include?(exception.object_id)
|
|
71
|
+
seen << exception.object_id
|
|
72
|
+
|
|
73
|
+
Errsight.capture_exception(
|
|
74
|
+
exception,
|
|
75
|
+
metadata: { sidekiq_context: stringify_keys(ctx) },
|
|
76
|
+
tags: { "sidekiq.source" => "error_handler" }
|
|
77
|
+
)
|
|
78
|
+
rescue StandardError
|
|
79
|
+
# Never let our handler tip Sidekiq into a meta-error loop.
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if config.respond_to?(:error_handlers)
|
|
83
|
+
config.error_handlers << handler
|
|
84
|
+
elsif ::Sidekiq.respond_to?(:error_handlers)
|
|
85
|
+
::Sidekiq.error_handlers << handler
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def stringify_keys(hash)
|
|
90
|
+
return {} unless hash.is_a?(Hash)
|
|
91
|
+
hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
class ClientMiddleware
|
|
96
|
+
include ::Sidekiq::ClientMiddleware if defined?(::Sidekiq::ClientMiddleware)
|
|
97
|
+
|
|
98
|
+
def call(_worker_class, job, _queue, _redis_pool)
|
|
99
|
+
snapshot = scope_snapshot
|
|
100
|
+
job["errsight_scope"] = snapshot unless snapshot.empty?
|
|
101
|
+
yield
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# Delegate to Scope#to_h, which encodes the "user breadcrumbs only;
|
|
107
|
+
# DB crumbs stay process-local" propagation rule. Cap the propagated
|
|
108
|
+
# crumbs to keep job payloads small in Redis on high-throughput queues.
|
|
109
|
+
def scope_snapshot
|
|
110
|
+
hash = Errsight.current_scope.to_h
|
|
111
|
+
if hash["breadcrumbs"].is_a?(Array) && hash["breadcrumbs"].size > MAX_PROPAGATED_BREADCRUMBS
|
|
112
|
+
hash["breadcrumbs"] = hash["breadcrumbs"].last(MAX_PROPAGATED_BREADCRUMBS)
|
|
113
|
+
end
|
|
114
|
+
hash
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
class ServerMiddleware
|
|
119
|
+
include ::Sidekiq::ServerMiddleware if defined?(::Sidekiq::ServerMiddleware)
|
|
120
|
+
|
|
121
|
+
def call(worker, job, queue)
|
|
122
|
+
Errsight.with_scope(scope_for_job(job)) do
|
|
123
|
+
run_with_capture(worker, job, queue) { yield }
|
|
124
|
+
end
|
|
125
|
+
ensure
|
|
126
|
+
# Match the dedup-set lifecycle from CaptureMiddleware: cleared at
|
|
127
|
+
# job boundary so the next job on the same Sidekiq thread starts
|
|
128
|
+
# fresh and isn't tricked into skipping a legitimately new error.
|
|
129
|
+
Thread.current[:errsight_captured_exceptions] = nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
# Start from the worker thread's root scope (which carries any
|
|
135
|
+
# process-wide tags set at boot) and overlay the snapshot shipped by
|
|
136
|
+
# the enqueuing process. Job context wins on conflicts because the
|
|
137
|
+
# enqueuer knew which user/request triggered the work.
|
|
138
|
+
def scope_for_job(job)
|
|
139
|
+
scope = Errsight.hub.current_scope.dup
|
|
140
|
+
snapshot = job["errsight_scope"]
|
|
141
|
+
scope.merge!(::Errsight::Scope.from_h(snapshot)) if snapshot.is_a?(Hash)
|
|
142
|
+
scope
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def run_with_capture(worker, job, queue)
|
|
146
|
+
yield
|
|
147
|
+
rescue Exception => exception
|
|
148
|
+
capture(exception, worker, job, queue)
|
|
149
|
+
raise
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def capture(exception, worker, job, queue)
|
|
153
|
+
seen = Thread.current[:errsight_captured_exceptions] ||= []
|
|
154
|
+
return if seen.include?(exception.object_id)
|
|
155
|
+
seen << exception.object_id
|
|
156
|
+
|
|
157
|
+
Errsight.capture_exception(
|
|
158
|
+
exception,
|
|
159
|
+
tags: build_tags(worker, job, queue),
|
|
160
|
+
metadata: { sidekiq: build_metadata(job) }
|
|
161
|
+
)
|
|
162
|
+
rescue StandardError
|
|
163
|
+
# Never let our own capture failure suppress the job's exception —
|
|
164
|
+
# Sidekiq still needs to see the original to retry.
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build_tags(worker, job, queue)
|
|
168
|
+
{
|
|
169
|
+
"sidekiq.worker" => worker_name(job, worker),
|
|
170
|
+
"sidekiq.queue" => queue.to_s,
|
|
171
|
+
"sidekiq.jid" => job["jid"].to_s,
|
|
172
|
+
"sidekiq.retry_count" => job["retry_count"].to_s
|
|
173
|
+
}.reject { |_, v| v.to_s.strip.empty? }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def build_metadata(job)
|
|
177
|
+
{
|
|
178
|
+
"args" => filter_args(args_for_metadata(job)),
|
|
179
|
+
"queue" => job["queue"],
|
|
180
|
+
"jid" => job["jid"],
|
|
181
|
+
"enqueued_at" => job["enqueued_at"],
|
|
182
|
+
"created_at" => job["created_at"],
|
|
183
|
+
"retry" => job["retry"],
|
|
184
|
+
"retry_count" => job["retry_count"]
|
|
185
|
+
}.compact
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def worker_name(job, worker)
|
|
189
|
+
# ActiveJob jobs land here with class = "ActiveJob::QueueAdapters::
|
|
190
|
+
# SidekiqAdapter::JobWrapper" and the real class in `wrapped`.
|
|
191
|
+
# Surface the real class so the issues UI doesn't group every AJ job
|
|
192
|
+
# under a single useless wrapper name.
|
|
193
|
+
job["wrapped"] || job["class"] || worker&.class&.name || "Unknown"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def args_for_metadata(job)
|
|
197
|
+
if job["wrapped"]
|
|
198
|
+
# ActiveJob: real args live at args[0]["arguments"].
|
|
199
|
+
wrapper = Array(job["args"]).first
|
|
200
|
+
wrapper.is_a?(Hash) ? (wrapper["arguments"] || []) : []
|
|
201
|
+
else
|
|
202
|
+
job["args"] || []
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def filter_args(args)
|
|
207
|
+
return [] unless args.is_a?(Array)
|
|
208
|
+
args.map { |arg| filter_one(arg) }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def filter_one(arg)
|
|
212
|
+
case arg
|
|
213
|
+
when Hash then filter_hash(arg)
|
|
214
|
+
when Array then arg.map { |a| filter_one(a) }
|
|
215
|
+
else truncate_value(arg)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def filter_hash(hash)
|
|
220
|
+
filter = self.class.parameter_filter
|
|
221
|
+
normalized = hash.transform_keys(&:to_s)
|
|
222
|
+
filtered = filter ? filter.filter(normalized) : normalized
|
|
223
|
+
filtered.transform_values { |v| filter_one(v) }
|
|
224
|
+
rescue StandardError
|
|
225
|
+
{ "_unfilterable" => "[#{hash.class.name}]" }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def truncate_value(value)
|
|
229
|
+
str = value.to_s
|
|
230
|
+
return value if str.bytesize <= MAX_ARG_BYTES
|
|
231
|
+
"[truncated #{value.class.name} #{str.bytesize}b]"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Cached at class level — Rails.application.config.filter_parameters
|
|
235
|
+
# is effectively immutable after boot; rebuilding the ParameterFilter
|
|
236
|
+
# per job is wasted allocation on the hot path.
|
|
237
|
+
def self.parameter_filter
|
|
238
|
+
return @parameter_filter if defined?(@parameter_filter)
|
|
239
|
+
@parameter_filter =
|
|
240
|
+
if defined?(::ActiveSupport::ParameterFilter) && defined?(::Rails) && ::Rails.application
|
|
241
|
+
::ActiveSupport::ParameterFilter.new(::Rails.application.config.filter_parameters)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
Errsight::Sidekiq.configure_integration! if defined?(::Sidekiq)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
module Errsight
|
|
2
|
+
# Reads source files to attach `pre_context`, `context_line`, and
|
|
3
|
+
# `post_context` to in_app backtrace frames — the snippet of code around
|
|
4
|
+
# the failing line that turns "stack trace at user.rb:42" into "here's
|
|
5
|
+
# what user.rb:42 looked like when it crashed."
|
|
6
|
+
#
|
|
7
|
+
# Reference: sentry-ruby's `Sentry::LineCache`. Their cache is per-process
|
|
8
|
+
# bounded LRU; ours is the same idea, smaller, no dependencies.
|
|
9
|
+
module SourceContext
|
|
10
|
+
# 5 lines before, 5 lines after — sentry's default and a comfortable
|
|
11
|
+
# debugging window. Larger values bloat events without adding value.
|
|
12
|
+
PRE_CONTEXT_LINES = 5
|
|
13
|
+
POST_CONTEXT_LINES = 5
|
|
14
|
+
|
|
15
|
+
# Cap each emitted line to keep events bounded. A 1MB minified JS line
|
|
16
|
+
# accidentally landing in a Ruby backtrace (it happens via Sprockets
|
|
17
|
+
# asset pipeline errors) shouldn't blow our 512KB ingestion limit.
|
|
18
|
+
MAX_LINE_BYTES = 256
|
|
19
|
+
|
|
20
|
+
# LRU bound. 100 files × ~100 lines avg × ~80 bytes per line = ~800KB
|
|
21
|
+
# cache footprint per process. Cheap. A request typically reads <10
|
|
22
|
+
# unique files for context; the cache absorbs cross-request sharing.
|
|
23
|
+
CACHE_SIZE = 100
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# Returns { pre_context:, context_line:, post_context: } or nil if the
|
|
27
|
+
# file can't be read (missing, eval'd, internal, permission denied,
|
|
28
|
+
# malformed encoding, …). Never raises — source-context failure must
|
|
29
|
+
# not cascade into a failed event capture.
|
|
30
|
+
def fetch(filename, lineno)
|
|
31
|
+
return nil unless filename.is_a?(String) && !filename.empty?
|
|
32
|
+
return nil unless lineno.is_a?(Integer) && lineno > 0
|
|
33
|
+
# Synthetic frames have no readable source.
|
|
34
|
+
return nil if filename.start_with?("<", "(")
|
|
35
|
+
|
|
36
|
+
lines = read_lines(filename)
|
|
37
|
+
return nil unless lines
|
|
38
|
+
|
|
39
|
+
idx = lineno - 1
|
|
40
|
+
return nil if idx < 0 || idx >= lines.size
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
pre_context: slice_with_truncation(lines, [ idx - PRE_CONTEXT_LINES, 0 ].max, idx - 1),
|
|
44
|
+
context_line: truncate(lines[idx]),
|
|
45
|
+
post_context: slice_with_truncation(lines, idx + 1, [ idx + POST_CONTEXT_LINES, lines.size - 1 ].min)
|
|
46
|
+
}
|
|
47
|
+
rescue StandardError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Test-only: drop the cache so a test that mutates a fixture file
|
|
52
|
+
# gets the fresh contents on the next fetch.
|
|
53
|
+
def reset_cache!
|
|
54
|
+
@cache = nil
|
|
55
|
+
@order = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def read_lines(filename)
|
|
61
|
+
@cache ||= {}
|
|
62
|
+
@order ||= []
|
|
63
|
+
|
|
64
|
+
if @cache.key?(filename)
|
|
65
|
+
# LRU touch: move to most-recent end.
|
|
66
|
+
@order.delete(filename)
|
|
67
|
+
@order << filename
|
|
68
|
+
return @cache[filename]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
return nil unless File.file?(filename) && File.readable?(filename)
|
|
72
|
+
|
|
73
|
+
# Force UTF-8; if the file has invalid bytes, fall back to binary
|
|
74
|
+
# so we don't raise. Source files in production should be clean
|
|
75
|
+
# UTF-8 but customer fixtures sometimes aren't.
|
|
76
|
+
contents = File.read(filename, mode: "r:utf-8")
|
|
77
|
+
contents = contents.scrub("?") unless contents.valid_encoding?
|
|
78
|
+
|
|
79
|
+
lines = contents.lines.map { |l| l.chomp }
|
|
80
|
+
@cache[filename] = lines
|
|
81
|
+
@order << filename
|
|
82
|
+
|
|
83
|
+
# Evict oldest until we're at or below the cap.
|
|
84
|
+
while @order.size > CACHE_SIZE
|
|
85
|
+
evicted = @order.shift
|
|
86
|
+
@cache.delete(evicted)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
lines
|
|
90
|
+
rescue StandardError
|
|
91
|
+
# File errors (permission denied, vanished mid-read, etc.) — return
|
|
92
|
+
# nil so fetch returns nil so the frame just lacks source context.
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def slice_with_truncation(lines, from, to)
|
|
97
|
+
return [] if to < from
|
|
98
|
+
lines[from..to].to_a.map { |l| truncate(l) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def truncate(line)
|
|
102
|
+
return line.to_s if line.nil?
|
|
103
|
+
line.bytesize > MAX_LINE_BYTES ? line.byteslice(0, MAX_LINE_BYTES) + "…[truncated]" : line
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
data/lib/errsight.rb
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require "errsight/version"
|
|
3
|
+
require "errsight/configuration"
|
|
4
|
+
require "errsight/scope"
|
|
5
|
+
require "errsight/hub"
|
|
6
|
+
require "errsight/backtrace"
|
|
7
|
+
require "errsight/source_context"
|
|
8
|
+
require "errsight/client"
|
|
9
|
+
require "errsight/logger"
|
|
10
|
+
require "errsight/middleware"
|
|
11
|
+
require "errsight/capture_middleware"
|
|
12
|
+
require "errsight/railtie" if defined?(Rails)
|
|
13
|
+
|
|
14
|
+
module Errsight
|
|
15
|
+
class Error < StandardError; end
|
|
16
|
+
class ConfigurationError < Error; end
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def configuration
|
|
20
|
+
@configuration ||= Configuration.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def configure
|
|
24
|
+
yield configuration
|
|
25
|
+
configuration.validate!
|
|
26
|
+
configuration
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def client
|
|
30
|
+
@client ||= Client.new(configuration)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def hub
|
|
34
|
+
Hub.current
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def current_scope
|
|
38
|
+
hub.current_scope
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Push a fresh scope for the duration of the block, then pop on exit.
|
|
42
|
+
# Used by request and job middleware so user/tags/breadcrumbs set during
|
|
43
|
+
# one unit of work don't leak to the next one handled by the same thread.
|
|
44
|
+
# Pass an explicit Scope to install a pre-built one (e.g. rehydrated
|
|
45
|
+
# from a Sidekiq job payload).
|
|
46
|
+
def with_scope(scope = nil, &block)
|
|
47
|
+
hub.with_scope(scope, &block)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Public scope mutators delegate to the current (top-of-stack) scope.
|
|
51
|
+
# Pre-scope-stack callers wrote directly to Thread.current and relied on
|
|
52
|
+
# the request middleware to clear; those reads bled across requests on
|
|
53
|
+
# long-lived Puma/Sidekiq threads. The stack guarantees cleanup at the
|
|
54
|
+
# block boundary regardless of caller hygiene.
|
|
55
|
+
def set_user(user); current_scope.set_user(user); end
|
|
56
|
+
def clear_user; current_scope.clear_user; end
|
|
57
|
+
def set_tag(key, value); current_scope.set_tag(key, value); end
|
|
58
|
+
def set_tags(tags); current_scope.set_tags(tags); end
|
|
59
|
+
def clear_tags; current_scope.clear_tags; end
|
|
60
|
+
def add_breadcrumb(**kwargs); current_scope.add_breadcrumb(**kwargs); end
|
|
61
|
+
def clear_breadcrumbs; current_scope.clear_breadcrumbs; end
|
|
62
|
+
|
|
63
|
+
def log(level:, message:, backtrace: nil, environment: nil, metadata: {},
|
|
64
|
+
occurred_at: nil, fingerprint: nil, user: nil, tags: nil, release: nil)
|
|
65
|
+
return unless configuration.enabled?
|
|
66
|
+
return if level_below_threshold?(level)
|
|
67
|
+
|
|
68
|
+
scope = current_scope
|
|
69
|
+
event = {
|
|
70
|
+
ingestion_id: SecureRandom.uuid,
|
|
71
|
+
level: level.to_s,
|
|
72
|
+
message: message.to_s,
|
|
73
|
+
backtrace: backtrace,
|
|
74
|
+
environment: environment || configuration.environment,
|
|
75
|
+
metadata: metadata,
|
|
76
|
+
occurred_at: (occurred_at || Time.now).iso8601(3),
|
|
77
|
+
release: release || configuration.release,
|
|
78
|
+
user: user || scope.user,
|
|
79
|
+
tags: merge_tags(scope.tags, tags),
|
|
80
|
+
breadcrumbs: scope.breadcrumbs
|
|
81
|
+
}
|
|
82
|
+
event[:fingerprint] = fingerprint if fingerprint
|
|
83
|
+
event.compact!
|
|
84
|
+
|
|
85
|
+
event = run_before_send(event)
|
|
86
|
+
return if event.nil?
|
|
87
|
+
|
|
88
|
+
client.enqueue(event)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def capture_exception(exception, metadata: {}, fingerprint: nil, user: nil, tags: nil)
|
|
92
|
+
return unless exception.is_a?(Exception)
|
|
93
|
+
|
|
94
|
+
enriched_metadata = metadata.merge(exception_class: exception.class.to_s)
|
|
95
|
+
causes = walk_exception_causes(exception)
|
|
96
|
+
enriched_metadata[:exception_causes] = causes if causes.any?
|
|
97
|
+
|
|
98
|
+
frames = build_frames(exception)
|
|
99
|
+
enriched_metadata[:exception_frames] = frames if frames.any?
|
|
100
|
+
|
|
101
|
+
log(
|
|
102
|
+
level: :error,
|
|
103
|
+
message: "#{exception.class}: #{exception.message}",
|
|
104
|
+
backtrace: exception.backtrace&.join("\n"),
|
|
105
|
+
metadata: enriched_metadata,
|
|
106
|
+
fingerprint: fingerprint,
|
|
107
|
+
user: user,
|
|
108
|
+
tags: tags
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# Hard cap on cause-chain depth. Exception#cause can technically loop if
|
|
115
|
+
# someone constructs a pathological chain manually; the seen-set guards
|
|
116
|
+
# against that, but the depth cap is the load-bearing protection.
|
|
117
|
+
MAX_CAUSE_DEPTH = 5
|
|
118
|
+
MAX_CAUSE_BACKTRACE_FRAMES = 20
|
|
119
|
+
|
|
120
|
+
# Parse the exception's backtrace into structured frames + attach
|
|
121
|
+
# source context to in_app frames. Ships in metadata[:exception_frames]
|
|
122
|
+
# alongside the legacy `backtrace` string so the backend can render
|
|
123
|
+
# whichever it knows how to show. Source context is the
|
|
124
|
+
# debugging-experience differentiator: stacks become "here's the line
|
|
125
|
+
# of your code that failed, with 5 lines of surrounding context."
|
|
126
|
+
def build_frames(exception)
|
|
127
|
+
raw = exception.backtrace
|
|
128
|
+
return [] unless raw.is_a?(Array) && raw.any?
|
|
129
|
+
|
|
130
|
+
frames = Backtrace.parse(raw)
|
|
131
|
+
frames.each do |frame|
|
|
132
|
+
next unless frame[:in_app] && frame[:abs_path]
|
|
133
|
+
ctx = SourceContext.fetch(frame[:abs_path], frame[:lineno])
|
|
134
|
+
frame.merge!(ctx) if ctx
|
|
135
|
+
end
|
|
136
|
+
frames
|
|
137
|
+
rescue StandardError
|
|
138
|
+
# Frame parsing must never break capture. Fall back to no structured
|
|
139
|
+
# frames; the legacy backtrace string still ships.
|
|
140
|
+
[]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Ruby chains exceptions via #cause when one rescue raises another. The
|
|
144
|
+
# outer exception's class+message is what the user sees in the issue
|
|
145
|
+
# title, but the inner cause is usually what actually broke (Net::Read
|
|
146
|
+
# Timeout under PaymentGatewayError). Surface the chain so the issue
|
|
147
|
+
# detail page can show "rescued from <inner>".
|
|
148
|
+
def walk_exception_causes(exception)
|
|
149
|
+
causes = []
|
|
150
|
+
current = exception.cause
|
|
151
|
+
seen = { exception.object_id => true }
|
|
152
|
+
while current && causes.size < MAX_CAUSE_DEPTH && !seen[current.object_id]
|
|
153
|
+
seen[current.object_id] = true
|
|
154
|
+
causes << {
|
|
155
|
+
class: current.class.to_s,
|
|
156
|
+
message: current.message.to_s,
|
|
157
|
+
backtrace: current.backtrace&.first(MAX_CAUSE_BACKTRACE_FRAMES)
|
|
158
|
+
}
|
|
159
|
+
current = current.cause
|
|
160
|
+
end
|
|
161
|
+
causes
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Customer-supplied final-mile filter. If it returns nil, the event is
|
|
165
|
+
# dropped silently. If it raises, we log the error and pass the event
|
|
166
|
+
# through unmodified — silently dropping production errors because the
|
|
167
|
+
# customer's filter has a bug is worse than the bug itself.
|
|
168
|
+
def run_before_send(event)
|
|
169
|
+
filter = configuration.before_send
|
|
170
|
+
return event unless filter.respond_to?(:call)
|
|
171
|
+
result = filter.call(event)
|
|
172
|
+
result.is_a?(Hash) ? result : nil
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
configuration.logger&.warn(
|
|
175
|
+
"[Errsight] before_send raised #{e.class}: #{e.message} — passing event through unmodified"
|
|
176
|
+
)
|
|
177
|
+
event
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def merge_tags(scope_tags, per_call)
|
|
181
|
+
return scope_tags if per_call.nil? || per_call.empty?
|
|
182
|
+
scope_tags.merge(per_call.transform_keys(&:to_s).transform_values(&:to_s))
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def level_below_threshold?(level)
|
|
186
|
+
level_value(level) < level_value(configuration.min_level)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def level_value(level)
|
|
190
|
+
%i[debug info warning error fatal].index(level.to_sym) || 0
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: errsight
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.2
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Errsight
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: concurrent-ruby
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.0'
|
|
26
|
+
description: A lightweight Ruby gem that hooks into Rails.logger and sends logs/errors
|
|
27
|
+
to the Errsight API.
|
|
28
|
+
email:
|
|
29
|
+
- support@errsight.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- CHANGELOG.md
|
|
35
|
+
- LICENSE
|
|
36
|
+
- README.md
|
|
37
|
+
- errsight.gemspec
|
|
38
|
+
- lib/errsight.rb
|
|
39
|
+
- lib/errsight/backtrace.rb
|
|
40
|
+
- lib/errsight/capture_middleware.rb
|
|
41
|
+
- lib/errsight/client.rb
|
|
42
|
+
- lib/errsight/configuration.rb
|
|
43
|
+
- lib/errsight/hub.rb
|
|
44
|
+
- lib/errsight/integrations/active_job.rb
|
|
45
|
+
- lib/errsight/integrations/active_record.rb
|
|
46
|
+
- lib/errsight/integrations/rails_error_reporter.rb
|
|
47
|
+
- lib/errsight/logger.rb
|
|
48
|
+
- lib/errsight/middleware.rb
|
|
49
|
+
- lib/errsight/railtie.rb
|
|
50
|
+
- lib/errsight/scope.rb
|
|
51
|
+
- lib/errsight/sidekiq.rb
|
|
52
|
+
- lib/errsight/source_context.rb
|
|
53
|
+
- lib/errsight/version.rb
|
|
54
|
+
homepage: https://errsight.com
|
|
55
|
+
licenses:
|
|
56
|
+
- MIT
|
|
57
|
+
metadata:
|
|
58
|
+
homepage_uri: https://errsight.com
|
|
59
|
+
source_code_uri: https://github.com/errsight/errsight-ruby
|
|
60
|
+
bug_tracker_uri: https://github.com/errsight/errsight-ruby/issues
|
|
61
|
+
changelog_uri: https://github.com/errsight/errsight-ruby/blob/main/CHANGELOG.md
|
|
62
|
+
rdoc_options: []
|
|
63
|
+
require_paths:
|
|
64
|
+
- lib
|
|
65
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '3.0'
|
|
70
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
requirements: []
|
|
76
|
+
rubygems_version: 4.0.3
|
|
77
|
+
specification_version: 4
|
|
78
|
+
summary: Ruby/Rails client for Errsight error tracking
|
|
79
|
+
test_files: []
|