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.
@@ -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