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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -0
- data/README.md +183 -3
- data/lib/koderift/rails/breadcrumb.rb +70 -0
- data/lib/koderift/rails/capture.rb +514 -0
- data/lib/koderift/rails/configuration.rb +38 -0
- data/lib/koderift/rails/controller.rb +21 -0
- data/lib/koderift/rails/event.rb +27 -0
- data/lib/koderift/rails/instrumentation.rb +64 -19
- data/lib/koderift/rails/lograge_config.rb +7 -2
- data/lib/koderift/rails/railtie.rb +14 -0
- data/lib/koderift/rails/rake_integration.rb +43 -0
- data/lib/koderift/rails/rum_helper.rb +60 -4
- data/lib/koderift/rails/scope.rb +163 -0
- data/lib/koderift/rails/sidekiq_middleware.rb +24 -1
- data/lib/koderift/rails/tracing.rb +195 -0
- data/lib/koderift/rails/version.rb +1 -1
- data/lib/koderift/rails.rb +187 -0
- metadata +7 -1
|
@@ -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
|