koderift-rails 1.3.4 → 2.1.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,514 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require 'koderift/rails/event'
6
+ require 'koderift/rails/scope'
7
+ require 'koderift/rails/breadcrumb'
8
+ require 'koderift/rails/lograge_config'
9
+
10
+ module Koderift
11
+ module Rails
12
+ # Emits a manually reported error, message or transaction as one JSON line
13
+ # on Rails.logger, which the koderift-agent tails and parses. See
14
+ # LogAgent::Parser#parse_capture_line for the reading end.
15
+ module Capture
16
+ MAX_MESSAGE = 2_000
17
+ MAX_EXCEPTION = 1_000
18
+ MAX_ERROR_CAUSE = 1_000
19
+ MAX_CONTEXT = 4_500
20
+
21
+ DEFAULT_MESSAGE_CLASS = 'Koderift::Message'
22
+
23
+ # Keys that must not appear ANYWHERE in the emitted JSON, at any depth.
24
+ #
25
+ # LogAgent::Parser dispatches on `"controller":`, `"method":` and
26
+ # `"job_class":` occurring anywhere in the line — the regexes are
27
+ # /\A\{.*"controller":/ and friends, not anchored to the top level. So a
28
+ # caller's `tags: { controller: 'x' }` would make an OLDER agent parse the
29
+ # whole capture as a Lograge request line: status 0, duration 0,
30
+ # log_source 'rails'. That silently corrupts the customer's APM
31
+ # throughput, p95 and Apdex, which is far worse than losing one tag.
32
+ # This is also why the payload says `component` and `http_method` rather
33
+ # than `controller` and `method`.
34
+ RESERVED_KEYS = %w[controller method job_class].freeze
35
+
36
+ # The shape ErrorFingerprint::EXCEPTION_CLASS_RE accepts as a class. A
37
+ # token that does not match cannot be grouped — the platform pins it to
38
+ # UnknownError, collapsing unrelated errors into one — so a bad token is
39
+ # replaced rather than emitted.
40
+ CLASS_TOKEN_RE = /\A[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*\z/.freeze
41
+
42
+ # Sentry's levels on the left, the platform's on the right. A level
43
+ # outside LogAnalyticsQueryService::LOG_LEVELS would produce rows the Log
44
+ # Explorer's level filter cannot select, so :debug and :warning fold
45
+ # rather than pass through.
46
+ LEVELS = {
47
+ 'debug' => 'info', 'info' => 'info', 'warning' => 'warn',
48
+ 'warn' => 'warn', 'error' => 'error', 'fatal' => 'fatal'
49
+ }.freeze
50
+
51
+ # Only these reach the Errors page: error_group_stats filters
52
+ # `level IN ('error','fatal')`. capture_message therefore only synthesises
53
+ # a grouping class at these levels — at :warn/:info the row would carry an
54
+ # exception that nothing could ever group.
55
+ GROUPING_LEVELS = %w[error fatal].freeze
56
+
57
+ LOGGER_METHODS = {
58
+ 'fatal' => :fatal, 'error' => :error, 'warn' => :warn, 'info' => :info
59
+ }.freeze
60
+
61
+ CAPTURED_IVAR = :@koderift_captured
62
+ LAST_EVENT_KEY = :koderift_last_event_id
63
+ CONTEXT_KEY = :koderift_context
64
+
65
+ # Fixed-window counter shared by every thread in the process.
66
+ #
67
+ # A capture_exception inside a hot loop or a retrying job can emit
68
+ # thousands of lines, and raw log lines are the billing source of truth
69
+ # for a customer's storage quota. Drops are never silent: one warn line
70
+ # when a window starts dropping, one more naming the count when it rolls
71
+ # over.
72
+ class RateLimiter
73
+ WINDOW_SECONDS = 60
74
+
75
+ def initialize
76
+ @mutex = Mutex.new
77
+ @window_start = nil
78
+ @count = 0
79
+ @dropped = 0
80
+ end
81
+
82
+ # Returns [allowed, warning_or_nil]. The warning is returned rather than
83
+ # logged here so the caller can log it OUTSIDE this mutex — the logger
84
+ # takes a lock of its own, and taking them in a fixed order here would
85
+ # be one more place to get lock ordering wrong.
86
+ def allow(limit)
87
+ limit = limit.to_i if limit
88
+ return [true, nil] if limit.nil? || limit <= 0
89
+
90
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
91
+ warning = nil
92
+
93
+ @mutex.synchronize do
94
+ if @window_start.nil? || (now - @window_start) >= WINDOW_SECONDS
95
+ if @dropped.positive?
96
+ warning = "[Koderift] rate limit dropped #{@dropped} capture(s) " \
97
+ "in the last #{WINDOW_SECONDS}s"
98
+ end
99
+ @window_start = now
100
+ @count = 0
101
+ @dropped = 0
102
+ end
103
+
104
+ if @count < limit
105
+ @count += 1
106
+ return [true, warning]
107
+ end
108
+
109
+ @dropped += 1
110
+ if @dropped == 1
111
+ warning = "[Koderift] capture rate limit of #{limit}/min reached — " \
112
+ 'dropping further captures this minute ' \
113
+ '(set config.max_captures_per_minute to raise or nil to disable)'
114
+ end
115
+ end
116
+
117
+ [false, warning]
118
+ end
119
+ end
120
+
121
+ class << self
122
+ # ---------------------------------------------------------------- API
123
+
124
+ def exception(error, message: nil, level: :error, tags: nil, extra: nil,
125
+ user: nil, contexts: nil, fingerprint: nil, backtrace: nil,
126
+ hint: nil, component: nil, action: nil, user_id: nil, &block)
127
+ return nil unless error.is_a?(Exception)
128
+
129
+ Scope.with_temporary(block) do
130
+ token = class_token(fingerprint || Scope.current.fingerprint)
131
+ natural = "#{error.class}: #{error.message}"
132
+ exc = truncate(token ? "#{token}: #{natural}" : natural, MAX_EXCEPTION)
133
+ frames = backtrace ? LogrageConfig.format_frames(Array(backtrace))
134
+ : LogrageConfig.format_backtrace(error)
135
+
136
+ dispatch(
137
+ level: level, exception: exc, message: message || natural,
138
+ stack_trace: frames, error_cause: error.cause&.message,
139
+ error: error, tags: tags, extra: extra, user: user,
140
+ contexts: contexts, component: component, action: action,
141
+ user_id: user_id, hint: hint
142
+ )
143
+ end
144
+ rescue StandardError => e
145
+ swallow(e)
146
+ end
147
+
148
+ def message(text, level: :info, name: nil, fingerprint: nil, tags: nil,
149
+ extra: nil, user: nil, contexts: nil, backtrace: nil, hint: nil,
150
+ component: nil, action: nil, user_id: nil, &block)
151
+ Scope.with_temporary(block) do
152
+ normalised = normalise_level(level)
153
+ exc = nil
154
+
155
+ if GROUPING_LEVELS.include?(normalised)
156
+ token = class_token(fingerprint || name || Scope.current.fingerprint) ||
157
+ DEFAULT_MESSAGE_CLASS
158
+ exc = truncate("#{token}: #{text}", MAX_EXCEPTION)
159
+ end
160
+
161
+ dispatch(
162
+ level: level, exception: exc, message: text,
163
+ stack_trace: backtrace ? LogrageConfig.format_frames(Array(backtrace)) : nil,
164
+ error_cause: nil, error: nil, tags: tags, extra: extra, user: user,
165
+ contexts: contexts, component: component, action: action,
166
+ user_id: user_id, hint: hint
167
+ )
168
+ end
169
+ rescue StandardError => e
170
+ swallow(e)
171
+ end
172
+
173
+ # Re-emits an Event returned by an earlier capture, keeping its
174
+ # event_id so the two lines correlate rather than looking unrelated.
175
+ def event(evt)
176
+ return nil unless evt.is_a?(Event)
177
+
178
+ dispatch(
179
+ level: evt.level, exception: evt.exception, message: evt.message,
180
+ stack_trace: nil, error_cause: nil, error: nil, tags: nil, extra: nil,
181
+ user: nil, contexts: nil, component: nil, action: nil, user_id: nil,
182
+ hint: nil, event_id: evt.event_id
183
+ )
184
+ rescue StandardError => e
185
+ swallow(e)
186
+ end
187
+
188
+ # Emits a finished transaction (see Tracing::Transaction#finish). Not
189
+ # sampled: sample_rate is Sentry's ERROR sample rate, and silently
190
+ # dropping units of work would make the APM view lie about throughput.
191
+ def transaction(component:, action:, message:, duration_ms:, request_id:,
192
+ trace_id:, result:, level: 'info')
193
+ return nil unless initialized?
194
+
195
+ allowed, warning = rate_limiter.allow(configuration.max_captures_per_minute)
196
+ log_warning(warning)
197
+ return nil unless allowed
198
+
199
+ event_id = Event.generate_id
200
+ normalised = normalise_level(level)
201
+
202
+ payload = base_payload(
203
+ event_id: event_id, level: normalised, exception: nil, message: message,
204
+ stack_trace: nil, error_cause: nil, tags: nil, extra: nil, user: nil,
205
+ contexts: nil, component: component, action: action, user_id: nil
206
+ ).merge(
207
+ duration: duration_ms,
208
+ spans: result[:spans],
209
+ query_count: result[:query_count],
210
+ partial_count: result[:partial_count],
211
+ search_count: result[:search_count]
212
+ )
213
+
214
+ payload[:request_id] = request_id
215
+ payload[:trace_id] = trace_id
216
+ payload[:breadcrumbs] = result[:breadcrumbs] if result[:breadcrumbs]
217
+
218
+ emit(payload.compact, normalised)
219
+ Event.new(event_id: event_id, level: normalised, message: message,
220
+ exception: nil, timestamp: Time.now.utc.iso8601)
221
+ rescue StandardError => e
222
+ swallow(e)
223
+ end
224
+
225
+ def initialized?
226
+ config = configuration
227
+ config.enabled && !config.project_token.to_s.empty?
228
+ end
229
+
230
+ def last_event_id
231
+ Thread.current[LAST_EVENT_KEY]
232
+ end
233
+
234
+ def exception_captured?(error)
235
+ return false unless error.is_a?(Exception)
236
+
237
+ error.instance_variable_defined?(CAPTURED_IVAR)
238
+ rescue StandardError
239
+ false
240
+ end
241
+
242
+ def rate_limiter
243
+ @rate_limiter ||= RateLimiter.new
244
+ end
245
+
246
+ def reset_rate_limiter!
247
+ @rate_limiter = RateLimiter.new
248
+ end
249
+
250
+ # ------------------------------------------------------------ internal
251
+
252
+ private
253
+
254
+ def configuration
255
+ Koderift::Rails.configuration
256
+ end
257
+
258
+ def dispatch(level:, exception:, message:, stack_trace:, error_cause:, error:,
259
+ tags:, extra:, user:, contexts:, component:, action:, user_id:,
260
+ hint:, event_id: nil)
261
+ return nil unless initialized?
262
+
263
+ if error
264
+ return nil if excluded?(error)
265
+ return nil if exception_captured?(error)
266
+ end
267
+
268
+ return nil unless sampled?
269
+
270
+ allowed, warning = rate_limiter.allow(configuration.max_captures_per_minute)
271
+ log_warning(warning)
272
+ return nil unless allowed
273
+
274
+ event_id ||= Event.generate_id
275
+ normalised = normalise_level(level)
276
+
277
+ payload = base_payload(
278
+ event_id: event_id, level: normalised, exception: exception,
279
+ message: message, stack_trace: stack_trace, error_cause: error_cause,
280
+ tags: tags, extra: extra, user: user, contexts: contexts,
281
+ component: component, action: action, user_id: user_id
282
+ )
283
+
284
+ crumbs = Breadcrumbs.current
285
+ payload[:breadcrumbs] = crumbs unless crumbs.empty?
286
+
287
+ payload = run_before_send(payload.compact, hint)
288
+ return nil if payload.nil?
289
+
290
+ return nil unless emit(payload, normalised)
291
+
292
+ mark_captured(error) if error
293
+ Thread.current[LAST_EVENT_KEY] = event_id
294
+
295
+ Event.new(event_id: event_id, level: normalised, message: message.to_s,
296
+ exception: exception, timestamp: Time.now.utc.iso8601)
297
+ end
298
+
299
+ # `koderift_capture` is deliberately the FIRST key: it lets the agent's
300
+ # marker regex match on the shortest possible prefix, and it keeps the
301
+ # line self-identifying for anyone reading the raw log.
302
+ def base_payload(event_id:, level:, exception:, message:, stack_trace:,
303
+ error_cause:, tags:, extra:, user:, contexts:,
304
+ component:, action:, user_id:)
305
+ scope = Scope.current
306
+ ambient = Thread.current[CONTEXT_KEY] || {}
307
+
308
+ merged_user = scope.user.merge(stringify(user))
309
+
310
+ {
311
+ koderift_capture: 1,
312
+ level: level,
313
+ exception: presence(exception),
314
+ message: truncate(message.to_s, MAX_MESSAGE),
315
+ stack_trace: presence(stack_trace),
316
+ error_cause: presence(truncate(error_cause.to_s, MAX_ERROR_CAUSE)),
317
+ component: presence(component || scope.component || ambient[:component]),
318
+ action: presence(action || scope.action || ambient[:action]),
319
+ request_id: presence(ambient[:request_id]),
320
+ trace_id: presence(Thread.current[:koderift_trace_id]),
321
+ user_id: presence(resolve_user_id(user_id, merged_user, ambient)),
322
+ path: presence(ambient[:path]),
323
+ http_method: presence(ambient[:http_method]),
324
+ context: build_context(event_id, scope, tags, extra,
325
+ contexts, merged_user)
326
+ }
327
+ end
328
+
329
+ def build_context(event_id, scope, tags, extra, contexts, merged_user)
330
+ merged_tags = scope.tags.merge(stringify(tags))
331
+ merged_extra = scope.extras.merge(stringify(extra))
332
+ merged_contexts = scope.contexts.merge(stringify(contexts))
333
+
334
+ context = { 'event_id' => event_id }
335
+ context['tags'] = merged_tags unless merged_tags.empty?
336
+ context['extra'] = merged_extra unless merged_extra.empty?
337
+ context['contexts'] = merged_contexts unless merged_contexts.empty?
338
+ context['user'] = merged_user unless merged_user.empty?
339
+
340
+ fit(scrub(context))
341
+ end
342
+
343
+ # Removes reserved keys and anything matching config.filter_params, at
344
+ # every depth — a password nested three hashes deep is still a password,
345
+ # and a reserved key nested three hashes deep still breaks old agents.
346
+ def scrub(value)
347
+ case value
348
+ when Hash
349
+ value.each_with_object({}) do |(k, v), out|
350
+ key = k.to_s
351
+ next if RESERVED_KEYS.include?(key)
352
+ next if filtered_key?(key)
353
+
354
+ out[key] = scrub(v)
355
+ end
356
+ when Array
357
+ value.map { |v| scrub(v) }
358
+ else
359
+ value
360
+ end
361
+ end
362
+
363
+ def filtered_key?(key)
364
+ configuration.filter_params.any? { |f| key.match?(/#{Regexp.escape(f)}/i) }
365
+ rescue StandardError
366
+ false
367
+ end
368
+
369
+ # Drops KEYS to fit the budget, never characters. Truncating serialised
370
+ # JSON would leave the log line unparseable, and the agent parses the
371
+ # line as one unit — so an oversized context would not lose its tail,
372
+ # it would lose the entire capture.
373
+ def fit(context)
374
+ return context if JSON.generate(context).bytesize <= MAX_CONTEXT
375
+
376
+ reduced = { 'event_id' => context['event_id'], 'truncated' => true }
377
+ return reduced unless context['tags']
378
+
379
+ with_tags = reduced.merge('tags' => context['tags'])
380
+ JSON.generate(with_tags).bytesize <= MAX_CONTEXT ? with_tags : reduced
381
+ rescue StandardError
382
+ { 'event_id' => context['event_id'] }
383
+ end
384
+
385
+ def resolve_user_id(explicit, merged_user, ambient)
386
+ return explicit.to_s unless explicit.nil?
387
+ return merged_user['id'].to_s unless merged_user['id'].nil?
388
+
389
+ controller = ambient[:controller_instance]
390
+ return nil unless controller
391
+
392
+ user = begin
393
+ configuration.current_user.call(controller)
394
+ rescue StandardError
395
+ nil
396
+ end
397
+ user.respond_to?(:id) ? user.id.to_s : nil
398
+ rescue StandardError
399
+ nil
400
+ end
401
+
402
+ def excluded?(error)
403
+ excluded = configuration.excluded_exceptions
404
+ return false if excluded.nil? || excluded.empty?
405
+
406
+ ancestors = error.class.ancestors.map { |a| a.name.to_s }
407
+ excluded.any? { |name| ancestors.include?(name.to_s) }
408
+ rescue StandardError
409
+ false
410
+ end
411
+
412
+ def sampled?
413
+ rate = configuration.sample_rate.to_f
414
+ return true if rate >= 1.0
415
+ return false if rate <= 0.0
416
+
417
+ Kernel.rand < rate
418
+ end
419
+
420
+ # A raising before_send emits the ORIGINAL payload rather than dropping
421
+ # it. A bug in a user callback must not silently blind their error
422
+ # reporting — that failure would look exactly like "nothing went wrong".
423
+ def run_before_send(payload, hint)
424
+ callback = configuration.before_send
425
+ return payload unless callback.respond_to?(:call)
426
+
427
+ result = callback.call(payload, hint)
428
+ result.is_a?(Hash) ? result : nil
429
+ rescue StandardError => e
430
+ log_warning("[Koderift] before_send raised #{e.class}: #{e.message} — " \
431
+ 'emitting the capture unmodified')
432
+ payload
433
+ end
434
+
435
+ def emit(payload, level)
436
+ logger = rails_logger
437
+ return false unless logger
438
+
439
+ logger.public_send(LOGGER_METHODS.fetch(level, :info), JSON.generate(payload))
440
+ true
441
+ rescue StandardError
442
+ false
443
+ end
444
+
445
+ def mark_captured(error)
446
+ error.instance_variable_set(CAPTURED_IVAR, true)
447
+ rescue StandardError
448
+ nil
449
+ end
450
+
451
+ def class_token(value)
452
+ return nil if value.nil?
453
+
454
+ parts = Array(value).map(&:to_s).reject(&:empty?)
455
+ return nil if parts.empty?
456
+ return parts.first if parts.size == 1 && CLASS_TOKEN_RE.match?(parts.first)
457
+
458
+ token = camelize(parts.join('_'))
459
+ CLASS_TOKEN_RE.match?(token) ? token : nil
460
+ end
461
+
462
+ def camelize(str)
463
+ str.gsub(/[^A-Za-z0-9_]+/, '_')
464
+ .split('_')
465
+ .reject(&:empty?)
466
+ .map { |w| w[0].upcase + w[1..-1].to_s }
467
+ .join
468
+ end
469
+
470
+ def normalise_level(level)
471
+ LEVELS.fetch(level.to_s.downcase, 'error')
472
+ end
473
+
474
+ def stringify(hash)
475
+ return {} unless hash.respond_to?(:each_pair)
476
+
477
+ hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
478
+ end
479
+
480
+ def truncate(value, limit)
481
+ str = value.to_s
482
+ str.length > limit ? str[0, limit] : str
483
+ end
484
+
485
+ def presence(value)
486
+ str = value.to_s
487
+ str.empty? ? nil : value
488
+ end
489
+
490
+ def rails_logger
491
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:logger)
492
+
493
+ ::Rails.logger
494
+ end
495
+
496
+ def log_warning(message)
497
+ return if message.nil?
498
+
499
+ rails_logger&.warn(message)
500
+ rescue StandardError
501
+ nil
502
+ end
503
+
504
+ # A capture must never take down the code it is reporting from. The
505
+ # whole point is that it sits in a rescue block that was already
506
+ # handling a bad day.
507
+ def swallow(error)
508
+ log_warning("[Koderift] capture failed: #{error.class}: #{error.message}")
509
+ nil
510
+ end
511
+ end
512
+ end
513
+ end
514
+ end
@@ -7,6 +7,14 @@ module Koderift
7
7
  # Set from an environment variable in the initializer.
8
8
  attr_accessor :project_token
9
9
 
10
+ # Public token for Browser RUM, rendered into page HTML by
11
+ # koderift_rum_meta_tag. It can only send browser RUM data, so it is safe
12
+ # to publish (and to commit). Copy it from your project's Log Analytics
13
+ # setup page in Koderift; it starts with "rum_". Never set it to
14
+ # project_token: that is the log agent's ingest secret, and anyone who
15
+ # reads it from your page source can send log lines to your project.
16
+ attr_accessor :rum_token
17
+
10
18
  # Lambda called with the controller instance to get the current user.
11
19
  # Default tries common patterns. Override if your app is different.
12
20
  # Example: ->(controller) { controller.current_user }
@@ -38,8 +46,34 @@ module Koderift
38
46
  # in the emitted log payload. Default 30_000 (30 seconds).
39
47
  attr_accessor :slow_job_threshold_ms
40
48
 
49
+ # Ceiling on manual captures (Koderift.capture_exception and friends) per
50
+ # minute, per process. A capture in a hot loop or a retrying job can emit
51
+ # thousands of lines, and raw log lines are the billing source of truth
52
+ # for log storage quota. Set to nil to disable the cap. Drops are always
53
+ # announced on the log -- never silent.
54
+ attr_accessor :max_captures_per_minute
55
+
56
+ # Fraction of manual captures to emit (0.0-1.0). Sentry's `sample_rate`.
57
+ # Applies to captures only, never to transactions: silently dropping
58
+ # units of work would make the APM view understate throughput.
59
+ attr_accessor :sample_rate
60
+
61
+ # Called with (payload_hash, hint) before a capture is emitted. Return the
62
+ # hash (optionally modified) to emit it, or nil to drop it. Sentry's
63
+ # `before_send`. If this callback itself raises, the ORIGINAL payload is
64
+ # emitted and a warning logged -- a bug in the callback must not silently
65
+ # blind error reporting.
66
+ attr_accessor :before_send
67
+
68
+ # Exception class names (as strings) that manual captures ignore, matched
69
+ # against the full ancestor chain. Sentry's `excluded_exceptions`.
70
+ # Defaults to empty: unlike Sentry we never capture automatically, so
71
+ # every call is something the application asked for explicitly.
72
+ attr_accessor :excluded_exceptions
73
+
41
74
  def initialize
42
75
  @project_token = nil
76
+ @rum_token = nil
43
77
  @current_user = default_current_user
44
78
  @filter_params = %w[
45
79
  password password_confirmation
@@ -52,6 +86,10 @@ module Koderift
52
86
  @rum_enabled = true
53
87
  @rum_sampling_rate = 0.1
54
88
  @slow_job_threshold_ms = 30_000
89
+ @max_captures_per_minute = 100
90
+ @sample_rate = 1.0
91
+ @before_send = nil
92
+ @excluded_exceptions = []
55
93
  end
56
94
 
57
95
  def valid?
@@ -3,6 +3,7 @@
3
3
  require 'active_support/concern'
4
4
  require 'securerandom'
5
5
  require 'koderift/rails/instrumentation'
6
+ require 'koderift/rails/scope'
6
7
 
7
8
  module Koderift
8
9
  module Rails
@@ -22,6 +23,21 @@ module Koderift
22
23
  Thread.current[:koderift_trace_id] = trace_id
23
24
  request.env['koderift.trace_id'] = trace_id
24
25
 
26
+ # Ambient context for manual captures. Koderift.capture_exception can be
27
+ # called from anywhere below this frame -- a model, a service object, a
28
+ # rescue three layers down -- and has no other way to learn which
29
+ # request it happened inside. The controller instance is kept so
30
+ # config.current_user can be resolved at capture time rather than eagerly
31
+ # on every request that never captures anything.
32
+ Thread.current[:koderift_context] = {
33
+ component: self.class.name,
34
+ action: action_name,
35
+ request_id: request.request_id,
36
+ path: request.path,
37
+ http_method: request.request_method,
38
+ controller_instance: self
39
+ }
40
+
25
41
  result = Koderift::Rails::Instrumentation.capture do
26
42
  yield
27
43
  end
@@ -33,6 +49,11 @@ module Koderift
33
49
  request.env['koderift.breadcrumbs'] = result[:breadcrumbs]
34
50
  ensure
35
51
  Thread.current[:koderift_trace_id] = nil
52
+ Thread.current[:koderift_context] = nil
53
+ # Puma reuses threads across requests. Without this reset a tag set for
54
+ # one user's request would silently attach to the next request served
55
+ # by the same thread.
56
+ Koderift::Rails::Scope.reset!
36
57
  end
37
58
 
38
59
  def append_info_to_payload(payload)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Koderift
6
+ module Rails
7
+ # The return value of a successful capture. Shaped after Sentry's event so
8
+ # the idiom that motivated this API keeps working after a namespace swap:
9
+ #
10
+ # event_id = Koderift.capture_exception(error)&.event_id
11
+ #
12
+ # A capture returns nil rather than an Event whenever it was not emitted —
13
+ # the gem is disabled, the class is excluded, sampling or the rate limiter
14
+ # dropped it, before_send vetoed it, or the exception was already captured.
15
+ # That is why callers reach for &. and it is why nil must stay a real,
16
+ # documented outcome rather than an error.
17
+ Event = Struct.new(:event_id, :level, :message, :exception, :timestamp,
18
+ keyword_init: true) do
19
+ # Sentry event ids are 32 lowercase hex characters, not a dashed UUID.
20
+ # Anything that already parses Sentry ids out of application logs keeps
21
+ # working against ours.
22
+ def self.generate_id
23
+ SecureRandom.uuid.delete('-')
24
+ end
25
+ end
26
+ end
27
+ end