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,107 @@
|
|
|
1
|
+
module Errsight
|
|
2
|
+
module Integrations
|
|
3
|
+
# Subscriber for Rails.error (ActiveSupport::ErrorReporter), the
|
|
4
|
+
# canonical error-reporting hook in Rails 7+. Catches errors from
|
|
5
|
+
# places our existing middleware + controller-notifications subscriber
|
|
6
|
+
# don't reach:
|
|
7
|
+
# - Active Job after retries are exhausted (Rails 7.1+ wraps job
|
|
8
|
+
# errors via Rails.error.report on death)
|
|
9
|
+
# - Active Storage analyzer/identifier errors
|
|
10
|
+
# - Action Mailer delivery failures
|
|
11
|
+
# - Action Cable channel errors
|
|
12
|
+
# - Anywhere app code calls Rails.error.handle / .record / .report
|
|
13
|
+
#
|
|
14
|
+
# The thread-local seen-set used by CaptureMiddleware and the
|
|
15
|
+
# process_action.action_controller subscriber dedups the overlap on
|
|
16
|
+
# controller errors — Rails internally calls Rails.error.report on
|
|
17
|
+
# controller exceptions too on Rails 7+, so without this dedup a
|
|
18
|
+
# single 500 would create two issues.
|
|
19
|
+
class RailsErrorReporter
|
|
20
|
+
MAX_CONTEXT_KEYS = 20
|
|
21
|
+
MAX_CONTEXT_VALUE_BYTES = 1_024
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# Idempotent — Rails initializers can fire twice during certain
|
|
25
|
+
# boot paths (engines, some test harnesses) and we don't want each
|
|
26
|
+
# report to fan out to N copies of ourselves.
|
|
27
|
+
def install!
|
|
28
|
+
return if @installed
|
|
29
|
+
@installed = true
|
|
30
|
+
::Rails.error.subscribe(new)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Test-only: lets a test reset state and reinstall against a fresh
|
|
34
|
+
# ActiveSupport::ErrorReporter mock.
|
|
35
|
+
def reset!
|
|
36
|
+
@installed = false
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# ActiveSupport::ErrorReporter signature has been stable across
|
|
41
|
+
# Rails 7.0–8.x. Trailing **kwargs swallows any additions in future
|
|
42
|
+
# versions so a Rails minor bump can't crash our subscriber.
|
|
43
|
+
def report(error, handled:, severity:, context: {}, source: nil, **)
|
|
44
|
+
return unless error.is_a?(Exception)
|
|
45
|
+
return if duplicate?(error)
|
|
46
|
+
|
|
47
|
+
Errsight.capture_exception(
|
|
48
|
+
error,
|
|
49
|
+
metadata: build_metadata(context, source, handled, severity),
|
|
50
|
+
tags: build_tags(severity, source, handled)
|
|
51
|
+
)
|
|
52
|
+
rescue StandardError
|
|
53
|
+
# Never let our reporter take down the host's request, job, or
|
|
54
|
+
# mail delivery — a missed event is far less bad than a crashed
|
|
55
|
+
# response.
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def duplicate?(exception)
|
|
61
|
+
seen = Thread.current[:errsight_captured_exceptions] ||= []
|
|
62
|
+
return true if seen.include?(exception.object_id)
|
|
63
|
+
seen << exception.object_id
|
|
64
|
+
false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_tags(severity, source, handled)
|
|
68
|
+
tags = {
|
|
69
|
+
"rails.error.severity" => severity.to_s,
|
|
70
|
+
"rails.error.handled" => handled.to_s
|
|
71
|
+
}
|
|
72
|
+
tags["rails.error.source"] = source.to_s if source
|
|
73
|
+
tags
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def build_metadata(context, source, handled, severity)
|
|
77
|
+
meta = {
|
|
78
|
+
rails_error: {
|
|
79
|
+
severity: severity.to_s,
|
|
80
|
+
handled: handled,
|
|
81
|
+
source: source&.to_s
|
|
82
|
+
}.compact
|
|
83
|
+
}
|
|
84
|
+
meta[:rails_error_context] = sanitize_context(context) if context.is_a?(Hash) && !context.empty?
|
|
85
|
+
meta
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def sanitize_context(context)
|
|
89
|
+
out = {}
|
|
90
|
+
context.first(MAX_CONTEXT_KEYS).each do |k, v|
|
|
91
|
+
out[k.to_s] = truncate(v)
|
|
92
|
+
end
|
|
93
|
+
out
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def truncate(value)
|
|
97
|
+
case value
|
|
98
|
+
when Hash, Array
|
|
99
|
+
value
|
|
100
|
+
else
|
|
101
|
+
str = value.to_s
|
|
102
|
+
str.bytesize > MAX_CONTEXT_VALUE_BYTES ? "[truncated #{value.class.name}]" : value
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
require "logger"
|
|
2
|
+
|
|
3
|
+
module Errsight
|
|
4
|
+
# A Logger-compatible class that forwards log entries to Errsight
|
|
5
|
+
# and optionally delegates to a backing logger.
|
|
6
|
+
class Logger < ::Logger
|
|
7
|
+
LEVEL_MAP = {
|
|
8
|
+
DEBUG => :debug,
|
|
9
|
+
INFO => :info,
|
|
10
|
+
WARN => :warning,
|
|
11
|
+
ERROR => :error,
|
|
12
|
+
FATAL => :fatal,
|
|
13
|
+
UNKNOWN => :info
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
LEVEL_ORDER = %i[debug info warning error fatal].freeze
|
|
17
|
+
|
|
18
|
+
# ActionDispatch::DebugExceptions logs a fully-formatted exception report
|
|
19
|
+
# via Rails.logger.fatal — class+msg, then a backtrace. Forwarding that
|
|
20
|
+
# blob as a single Event.message produces a giant title with no real
|
|
21
|
+
# backtrace column. The Rack CaptureMiddleware handles the underlying
|
|
22
|
+
# exception with structured fields, so we suppress these dumps here.
|
|
23
|
+
EXCEPTION_FRAME_RE = /:\d+:in ['`]/.freeze
|
|
24
|
+
EXCEPTION_FRAME_THRESHOLD = 3
|
|
25
|
+
|
|
26
|
+
def initialize(backing_logger = nil)
|
|
27
|
+
super(IO::NULL)
|
|
28
|
+
@backing_logger = backing_logger
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def add(severity, message = nil, progname = nil, &block)
|
|
32
|
+
# Duck-typed forward — when this Logger is used as a broadcast
|
|
33
|
+
# target by ActiveSupport::BroadcastLogger or the legacy
|
|
34
|
+
# Logger.broadcast extension, the constructor can be called with
|
|
35
|
+
# something that isn't a Logger (an IO, a Tagged-Logger wrapper,
|
|
36
|
+
# etc). Sending #add to an IO raises NoMethodError. Rather than
|
|
37
|
+
# try to enforce the type at construction (every host has its
|
|
38
|
+
# own quirks), we just skip forwarding if the target can't
|
|
39
|
+
# accept #add.
|
|
40
|
+
@backing_logger.add(severity, message, progname, &block) if @backing_logger.respond_to?(:add)
|
|
41
|
+
|
|
42
|
+
# Cheap early-return: skip all allocations when Errsight is disabled
|
|
43
|
+
# or this severity is below the configured threshold. Rails.logger can
|
|
44
|
+
# fire thousands of times per request at :debug; this path matters.
|
|
45
|
+
config = Errsight.configuration
|
|
46
|
+
return true unless config.enabled?
|
|
47
|
+
|
|
48
|
+
level = LEVEL_MAP[severity] || :info
|
|
49
|
+
return true if LEVEL_ORDER.index(level).to_i < LEVEL_ORDER.index(config.min_level).to_i
|
|
50
|
+
|
|
51
|
+
message = block_given? ? yield : message
|
|
52
|
+
message ||= progname
|
|
53
|
+
return true if message.nil?
|
|
54
|
+
|
|
55
|
+
message_str = message.to_s
|
|
56
|
+
return true if exception_dump?(message_str)
|
|
57
|
+
|
|
58
|
+
metadata = {}
|
|
59
|
+
if (request_id = Thread.current[:errsight_request_id])
|
|
60
|
+
metadata[:request_id] = request_id
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The Logger sits on Rails.logger's hot path. If Errsight.log raises
|
|
64
|
+
# for any reason — config drift, queue full mid-shutdown, a
|
|
65
|
+
# before_send callback bug — the customer's request handler must
|
|
66
|
+
# not crash because they happened to log a line. Swallow and move
|
|
67
|
+
# on; the original log line still went to @backing_logger above.
|
|
68
|
+
begin
|
|
69
|
+
Errsight.log(level: level, message: message_str, metadata: metadata)
|
|
70
|
+
rescue StandardError
|
|
71
|
+
# best-effort
|
|
72
|
+
end
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
alias log add
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def exception_dump?(message)
|
|
81
|
+
return false unless message.include?("\n")
|
|
82
|
+
message.scan(EXCEPTION_FRAME_RE).size >= EXCEPTION_FRAME_THRESHOLD
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Errsight
|
|
2
|
+
# Rack middleware that stores the request_id on the current thread
|
|
3
|
+
# so the Errsight::Logger can tag every log line with it.
|
|
4
|
+
class Middleware
|
|
5
|
+
def initialize(app)
|
|
6
|
+
@app = app
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(env)
|
|
10
|
+
Thread.current[:errsight_request_id] = env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"]
|
|
11
|
+
@app.call(env)
|
|
12
|
+
ensure
|
|
13
|
+
Thread.current[:errsight_request_id] = nil
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
require "rails"
|
|
2
|
+
require "socket"
|
|
3
|
+
|
|
4
|
+
module Errsight
|
|
5
|
+
class Railtie < Rails::Railtie
|
|
6
|
+
# Captured once at boot — Socket.gethostname does a DNS lookup on some
|
|
7
|
+
# platforms and is called on the hot exception-capture path.
|
|
8
|
+
HOSTNAME = begin
|
|
9
|
+
Socket.gethostname
|
|
10
|
+
rescue StandardError
|
|
11
|
+
nil
|
|
12
|
+
end.freeze
|
|
13
|
+
initializer "errsight.configure_rails_initialization" do |app|
|
|
14
|
+
# Ensure the client shuts down cleanly on exit
|
|
15
|
+
at_exit { Errsight.client.shutdown! rescue nil }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Insert middleware to capture request_id for log grouping
|
|
19
|
+
initializer "errsight.insert_middleware" do |app|
|
|
20
|
+
app.middleware.insert_after ActionDispatch::RequestId, Errsight::Middleware
|
|
21
|
+
|
|
22
|
+
# Sit just inside DebugExceptions so we see exceptions raised by any
|
|
23
|
+
# inner middleware (e.g. ActiveRecord::Migration::CheckPending) before
|
|
24
|
+
# they get converted into an HTTP error response. This is the only path
|
|
25
|
+
# that catches non-controller errors with a real backtrace.
|
|
26
|
+
app.middleware.insert_after ActionDispatch::DebugExceptions, Errsight::CaptureMiddleware
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Broadcast every Rails.logger call to Errsight so all log lines are captured,
|
|
30
|
+
# not just exceptions. Uses BroadcastLogger (Rails 7.1+) or the older broadcast
|
|
31
|
+
# extension. Respects config.min_level — set it to :debug/:info in the initializer
|
|
32
|
+
# to control how much noise is forwarded.
|
|
33
|
+
#
|
|
34
|
+
# MUST run `after: :load_config_initializers` so the user's
|
|
35
|
+
# `config/initializers/errsight.rb` has already executed by the time
|
|
36
|
+
# we read `attach_to_rails_logger`. Pre-0.2.0 the default was `true`
|
|
37
|
+
# so broadcasting wired up at boot regardless of user config; when
|
|
38
|
+
# the default flipped to `false` in 0.2.0 this initializer started
|
|
39
|
+
# running before the user could opt back in, and the broadcast
|
|
40
|
+
# never attached. Same hook the other 0.2.0 integrations use.
|
|
41
|
+
initializer "errsight.attach_to_rails_logger", after: :load_config_initializers do
|
|
42
|
+
next unless Errsight.configuration.attach_to_rails_logger
|
|
43
|
+
|
|
44
|
+
sink = Errsight::Logger.new # no backing logger — just forwards to the API
|
|
45
|
+
|
|
46
|
+
if Rails.logger.respond_to?(:broadcast_to)
|
|
47
|
+
# Rails 7.1+ ActiveSupport::BroadcastLogger
|
|
48
|
+
Rails.logger.broadcast_to(sink)
|
|
49
|
+
else
|
|
50
|
+
Rails.logger.extend(ActiveSupport::Logger.broadcast(sink))
|
|
51
|
+
end
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
Rails.logger.warn("[Errsight] Could not attach to Rails.logger: #{e.message}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Configure Sidekiq integration after the host's own initializers run,
|
|
57
|
+
# so any custom middleware they add is already in the chain when we
|
|
58
|
+
# decide where to insert ours. Bundler.require has already happened by
|
|
59
|
+
# this point — defined?(::Sidekiq) is the truthful answer.
|
|
60
|
+
initializer "errsight.configure_sidekiq", after: :load_config_initializers do
|
|
61
|
+
if defined?(::Sidekiq)
|
|
62
|
+
require "errsight/sidekiq"
|
|
63
|
+
Errsight::Sidekiq.configure_integration!
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Subscribe to sql.active_record so every query becomes a breadcrumb
|
|
68
|
+
# on the current scope. When an exception fires, the event ships with
|
|
69
|
+
# the queries that ran in this request/job — which is the Rails
|
|
70
|
+
# debugging information customers actually need. Run after host
|
|
71
|
+
# initializers so the customer's filter_parameters / config tweaks are
|
|
72
|
+
# available to us.
|
|
73
|
+
initializer "errsight.subscribe_active_record_breadcrumbs", after: :load_config_initializers do
|
|
74
|
+
if defined?(::ActiveRecord) && Errsight.configuration.breadcrumbs_active_record
|
|
75
|
+
require "errsight/integrations/active_record"
|
|
76
|
+
Errsight::Integrations::ActiveRecord.subscribe!
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Hook into Rails.error (ActiveSupport::ErrorReporter) on Rails 7+ so
|
|
81
|
+
# we catch errors from Active Job, Active Storage, Action Mailer,
|
|
82
|
+
# Action Cable, and any explicit Rails.error.handle/record/report
|
|
83
|
+
# calls — error sources our middleware + controller-notifications
|
|
84
|
+
# subscriber don't reach.
|
|
85
|
+
initializer "errsight.subscribe_rails_error_reporter", after: :load_config_initializers do
|
|
86
|
+
if ::Rails.respond_to?(:error) && ::Rails.error.respond_to?(:subscribe)
|
|
87
|
+
require "errsight/integrations/rails_error_reporter"
|
|
88
|
+
Errsight::Integrations::RailsErrorReporter.install!
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Include the ActiveJob integration into ::ActiveJob::Base so every
|
|
93
|
+
# job class picks up scope propagation (enqueue → run) and structured
|
|
94
|
+
# error capture. Adapter-agnostic: works for Solid Queue, GoodJob,
|
|
95
|
+
# Delayed::Job, and Sidekiq+ActiveJob. Sidekiq raw jobs (without
|
|
96
|
+
# ActiveJob) keep going through the dedicated Sidekiq middleware.
|
|
97
|
+
initializer "errsight.include_active_job_integration", after: :load_config_initializers do
|
|
98
|
+
if defined?(::ActiveJob::Base)
|
|
99
|
+
require "errsight/integrations/active_job"
|
|
100
|
+
::ActiveJob::Base.include(Errsight::Integrations::ActiveJob)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Hook into the Rails exception notification pipeline
|
|
105
|
+
initializer "errsight.subscribe_to_exceptions" do
|
|
106
|
+
ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
|
|
107
|
+
# Fast-path: skip Event allocation when the action didn't raise.
|
|
108
|
+
# args = [name, started, finished, unique_id, payload]
|
|
109
|
+
payload = args[4]
|
|
110
|
+
next unless payload.is_a?(Hash) && payload[:exception_object]
|
|
111
|
+
|
|
112
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
113
|
+
exception = event.payload[:exception_object]
|
|
114
|
+
request = event.payload[:request]
|
|
115
|
+
|
|
116
|
+
metadata = {
|
|
117
|
+
path: event.payload[:path],
|
|
118
|
+
full_path: request&.url,
|
|
119
|
+
format: event.payload[:format],
|
|
120
|
+
duration: event.duration.round(2)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# Attach POST/PATCH/PUT/DELETE params (skip Rails internals)
|
|
124
|
+
if request && %w[POST PATCH PUT DELETE].include?(request.request_method)
|
|
125
|
+
begin
|
|
126
|
+
filtered = request.filtered_parameters
|
|
127
|
+
.except("controller", "action", "format", "authenticity_token")
|
|
128
|
+
metadata[:params] = filtered unless filtered.empty?
|
|
129
|
+
rescue StandardError
|
|
130
|
+
# filtered_parameters can raise on malformed bodies — ignore
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
user_ctx = Errsight::Railtie.build_user_context(request)
|
|
135
|
+
tags = Errsight::Railtie.build_tags(event.payload, request)
|
|
136
|
+
|
|
137
|
+
# Mark this exception so CaptureMiddleware (which sits below us in the
|
|
138
|
+
# Rack stack and will see the same object as it bubbles up) skips the
|
|
139
|
+
# duplicate capture. The middleware clears this thread-local per-req.
|
|
140
|
+
(Thread.current[:errsight_captured_exceptions] ||= []) << exception.object_id
|
|
141
|
+
|
|
142
|
+
Errsight.capture_exception(exception, metadata: metadata, user: user_ctx, tags: tags)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Extracts a top-level user context the API stores in user_context /
|
|
147
|
+
# user_identifier. Falls through to just the request IP when no user is
|
|
148
|
+
# signed in so anonymous errors still carry some identity.
|
|
149
|
+
def self.build_user_context(request)
|
|
150
|
+
ctx = {}
|
|
151
|
+
|
|
152
|
+
# Warden-backed auth (Devise, ActiveAdmin, etc.). Walks every authenticated
|
|
153
|
+
# scope in the session rather than hard-coding :user.
|
|
154
|
+
begin
|
|
155
|
+
warden = request&.env&.fetch("warden", nil)
|
|
156
|
+
if warden
|
|
157
|
+
session = request.env["rack.session"] || {}
|
|
158
|
+
scopes = session.keys
|
|
159
|
+
.grep(/\Awarden\.user\.(.+)\.key\z/) { $1.to_sym }
|
|
160
|
+
.then { |s| s.any? ? s : [ :user ] }
|
|
161
|
+
|
|
162
|
+
user = scopes.lazy.filter_map { |scope| warden.user(scope) rescue nil }.first
|
|
163
|
+
|
|
164
|
+
if user
|
|
165
|
+
ctx[:id] = user.id.to_s if user.respond_to?(:id) && !user.id.to_s.strip.empty?
|
|
166
|
+
ctx[:email] = user.email.to_s if user.respond_to?(:email) && !user.email.to_s.strip.empty?
|
|
167
|
+
ctx[:username] = user.username.to_s if user.respond_to?(:username) && !user.username.to_s.strip.empty?
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
rescue StandardError
|
|
171
|
+
# never let user-lookup failures suppress the event
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
if request.respond_to?(:remote_ip)
|
|
175
|
+
ip = request.remote_ip.to_s
|
|
176
|
+
ctx[:ip_address] = ip unless ip.empty?
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
ctx.empty? ? nil : ctx
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Top-level tags the UI uses for filtering. Rails env info is free at
|
|
183
|
+
# capture time and makes "errors on this controller/action/host" filters
|
|
184
|
+
# work without any app-side setup.
|
|
185
|
+
def self.build_tags(payload, request)
|
|
186
|
+
tags = {
|
|
187
|
+
"controller" => payload[:controller],
|
|
188
|
+
"action" => payload[:action],
|
|
189
|
+
"request_method" => request&.request_method,
|
|
190
|
+
"status" => payload[:status]&.to_s,
|
|
191
|
+
"ruby_version" => RUBY_VERSION,
|
|
192
|
+
"rails_version" => Rails.version
|
|
193
|
+
}
|
|
194
|
+
tags["hostname"] = HOSTNAME if HOSTNAME
|
|
195
|
+
tags.compact.reject { |_, v| v.to_s.strip.empty? }
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
module Errsight
|
|
2
|
+
# Holds the user, tags, and breadcrumbs that should be attached to events
|
|
3
|
+
# captured while this scope is on top of the hub stack.
|
|
4
|
+
#
|
|
5
|
+
# A scope is owned by a single thread of execution (a Rails request, a
|
|
6
|
+
# Sidekiq job, or an ad-hoc Errsight.with_scope block). Pushing a new scope
|
|
7
|
+
# forks a deep copy of the parent so child mutations don't bleed back up
|
|
8
|
+
# the stack.
|
|
9
|
+
#
|
|
10
|
+
# Breadcrumbs are split into two ring buffers — manual app crumbs and
|
|
11
|
+
# auto-collected DB crumbs — so a high-query request can't evict the
|
|
12
|
+
# user's manual context. The public `breadcrumbs` accessor returns a
|
|
13
|
+
# merged, timestamp-sorted view; consumers see one stream.
|
|
14
|
+
class Scope
|
|
15
|
+
MAX_USER_BREADCRUMBS = 50
|
|
16
|
+
MAX_DB_BREADCRUMBS = 30
|
|
17
|
+
# Back-compat alias for callers that referenced the old single-cap name.
|
|
18
|
+
BREADCRUMB_LIMIT = MAX_USER_BREADCRUMBS
|
|
19
|
+
|
|
20
|
+
attr_reader :user, :tags
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@user = nil
|
|
24
|
+
@tags = {}
|
|
25
|
+
@user_breadcrumbs = []
|
|
26
|
+
@db_breadcrumbs = []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Merged, timestamp-sorted view across both rings. ISO-8601 strings sort
|
|
30
|
+
# lexicographically the same as chronologically, so a string sort is
|
|
31
|
+
# correct without parsing back into Time.
|
|
32
|
+
def breadcrumbs
|
|
33
|
+
return @user_breadcrumbs if @db_breadcrumbs.empty?
|
|
34
|
+
return @db_breadcrumbs if @user_breadcrumbs.empty?
|
|
35
|
+
(@user_breadcrumbs + @db_breadcrumbs).sort_by { |b| b[:timestamp] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def set_user(user)
|
|
39
|
+
@user = user.is_a?(Hash) ? user : nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def clear_user
|
|
43
|
+
@user = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def set_tag(key, value)
|
|
47
|
+
return if key.nil?
|
|
48
|
+
@tags[key.to_s] = value.to_s
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def set_tags(tags)
|
|
52
|
+
return unless tags.is_a?(Hash)
|
|
53
|
+
tags.each { |k, v| set_tag(k, v) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def clear_tags
|
|
57
|
+
@tags = {}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def add_breadcrumb(category:, message:, level: :info, data: nil)
|
|
61
|
+
@user_breadcrumbs << build_crumb(category, message, level, data)
|
|
62
|
+
@user_breadcrumbs.shift while @user_breadcrumbs.size > MAX_USER_BREADCRUMBS
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Internal API for auto-instrumentation (sql.active_record subscriber
|
|
66
|
+
# today; future http subscribers will use the same ring or get their
|
|
67
|
+
# own). Separate cap from manual crumbs so a runaway-query request
|
|
68
|
+
# can't push out the app code's own context.
|
|
69
|
+
def add_db_breadcrumb(message:, data: nil)
|
|
70
|
+
@db_breadcrumbs << build_crumb("db", message, :info, data)
|
|
71
|
+
@db_breadcrumbs.shift while @db_breadcrumbs.size > MAX_DB_BREADCRUMBS
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def clear_breadcrumbs
|
|
75
|
+
@user_breadcrumbs = []
|
|
76
|
+
@db_breadcrumbs = []
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Overlay another scope's state onto this one. Used by Sidekiq server
|
|
80
|
+
# middleware to layer payload scope (user/tags shipped by the enqueuer)
|
|
81
|
+
# on top of process-wide root state (e.g. Errsight.set_tag("region",…)
|
|
82
|
+
# called once at boot). User from `other` wins; tags are merged with
|
|
83
|
+
# `other` taking precedence on key collisions; breadcrumbs are appended
|
|
84
|
+
# in order and clipped to the limit.
|
|
85
|
+
def merge!(other)
|
|
86
|
+
return self unless other.is_a?(Scope)
|
|
87
|
+
@user = other.user if other.user
|
|
88
|
+
@tags.merge!(other.tags) unless other.tags.empty?
|
|
89
|
+
other_user = other.instance_variable_get(:@user_breadcrumbs)
|
|
90
|
+
other_db = other.instance_variable_get(:@db_breadcrumbs)
|
|
91
|
+
unless other_user.empty?
|
|
92
|
+
@user_breadcrumbs.concat(other_user.map(&:dup))
|
|
93
|
+
@user_breadcrumbs.shift while @user_breadcrumbs.size > MAX_USER_BREADCRUMBS
|
|
94
|
+
end
|
|
95
|
+
unless other_db.empty?
|
|
96
|
+
@db_breadcrumbs.concat(other_db.map(&:dup))
|
|
97
|
+
@db_breadcrumbs.shift while @db_breadcrumbs.size > MAX_DB_BREADCRUMBS
|
|
98
|
+
end
|
|
99
|
+
self
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Deep-ish copy used when pushing a child scope. Hashes/arrays are dup'd
|
|
103
|
+
# so child mutations don't bleed back up the stack.
|
|
104
|
+
def dup
|
|
105
|
+
copy = Scope.new
|
|
106
|
+
copy.send(:replace_state,
|
|
107
|
+
@user&.dup,
|
|
108
|
+
@tags.dup,
|
|
109
|
+
@user_breadcrumbs.map(&:dup),
|
|
110
|
+
@db_breadcrumbs.map(&:dup))
|
|
111
|
+
copy
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Serialize for cross-process propagation (Sidekiq client middleware will
|
|
115
|
+
# stash this in the job payload so the server middleware can rehydrate
|
|
116
|
+
# it before the job runs).
|
|
117
|
+
#
|
|
118
|
+
# Only manual user breadcrumbs travel across process boundaries. The
|
|
119
|
+
# receiving worker collects its own DB breadcrumbs from its own queries
|
|
120
|
+
# — propagating the parent's would mix DB events from two unrelated
|
|
121
|
+
# connection states and confuse debugging.
|
|
122
|
+
def to_h
|
|
123
|
+
hash = {}
|
|
124
|
+
hash["user"] = @user unless @user.nil?
|
|
125
|
+
hash["tags"] = @tags unless @tags.empty?
|
|
126
|
+
hash["breadcrumbs"] = @user_breadcrumbs unless @user_breadcrumbs.empty?
|
|
127
|
+
hash
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.from_h(hash)
|
|
131
|
+
scope = new
|
|
132
|
+
return scope unless hash.is_a?(Hash)
|
|
133
|
+
tags = hash["tags"].is_a?(Hash) ? hash["tags"].transform_keys(&:to_s).transform_values(&:to_s) : {}
|
|
134
|
+
# dup each crumb so the rehydrated scope doesn't alias entries inside
|
|
135
|
+
# the caller's job payload — add_breadcrumb mutates @user_breadcrumbs
|
|
136
|
+
# in place and we don't want that mutation to flow back into job args.
|
|
137
|
+
crumbs = hash["breadcrumbs"].is_a?(Array) ? hash["breadcrumbs"].map { |c| c.is_a?(Hash) ? c.dup : c } : []
|
|
138
|
+
# send: replace_state is protected so external callers can't reach in,
|
|
139
|
+
# but a class-method factory needs to bypass that to build a new scope.
|
|
140
|
+
# DB breadcrumbs are intentionally not propagated; they start empty.
|
|
141
|
+
scope.send(:replace_state, hash["user"], tags, crumbs, [])
|
|
142
|
+
scope
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
protected
|
|
146
|
+
|
|
147
|
+
def replace_state(user, tags, user_breadcrumbs, db_breadcrumbs)
|
|
148
|
+
@user = user
|
|
149
|
+
@tags = tags
|
|
150
|
+
@user_breadcrumbs = user_breadcrumbs
|
|
151
|
+
@db_breadcrumbs = db_breadcrumbs
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def build_crumb(category, message, level, data)
|
|
157
|
+
{
|
|
158
|
+
timestamp: Time.now.iso8601(3),
|
|
159
|
+
category: category.to_s,
|
|
160
|
+
level: level.to_s,
|
|
161
|
+
message: message.to_s,
|
|
162
|
+
data: data.is_a?(Hash) ? data : nil
|
|
163
|
+
}.compact
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|