lapsoss 0.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +855 -0
- data/lib/lapsoss/adapters/appsignal_adapter.rb +136 -0
- data/lib/lapsoss/adapters/base.rb +88 -0
- data/lib/lapsoss/adapters/insight_hub_adapter.rb +190 -0
- data/lib/lapsoss/adapters/logger_adapter.rb +67 -0
- data/lib/lapsoss/adapters/rollbar_adapter.rb +157 -0
- data/lib/lapsoss/adapters/sentry_adapter.rb +197 -0
- data/lib/lapsoss/backtrace_frame.rb +258 -0
- data/lib/lapsoss/backtrace_processor.rb +346 -0
- data/lib/lapsoss/client.rb +115 -0
- data/lib/lapsoss/configuration.rb +310 -0
- data/lib/lapsoss/current.rb +9 -0
- data/lib/lapsoss/event.rb +107 -0
- data/lib/lapsoss/exclusions.rb +429 -0
- data/lib/lapsoss/fingerprinter.rb +217 -0
- data/lib/lapsoss/http_client.rb +79 -0
- data/lib/lapsoss/middleware.rb +353 -0
- data/lib/lapsoss/pipeline.rb +131 -0
- data/lib/lapsoss/railtie.rb +72 -0
- data/lib/lapsoss/registry.rb +114 -0
- data/lib/lapsoss/release_tracker.rb +553 -0
- data/lib/lapsoss/router.rb +36 -0
- data/lib/lapsoss/sampling.rb +332 -0
- data/lib/lapsoss/scope.rb +110 -0
- data/lib/lapsoss/scrubber.rb +170 -0
- data/lib/lapsoss/user_context.rb +355 -0
- data/lib/lapsoss/validators.rb +142 -0
- data/lib/lapsoss/version.rb +5 -0
- data/lib/lapsoss.rb +76 -0
- metadata +217 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "active_support/cache"
|
|
5
|
+
require "active_support/core_ext/numeric/time"
|
|
6
|
+
require_relative "backtrace_frame"
|
|
7
|
+
|
|
8
|
+
module Lapsoss
|
|
9
|
+
class BacktraceProcessor
|
|
10
|
+
DEFAULT_CONFIG = {
|
|
11
|
+
context_lines: 3,
|
|
12
|
+
max_frames: 100,
|
|
13
|
+
enable_code_context: true,
|
|
14
|
+
strip_load_path: true,
|
|
15
|
+
in_app_patterns: [],
|
|
16
|
+
exclude_patterns: [
|
|
17
|
+
# Common test/debug patterns to exclude
|
|
18
|
+
/rspec/,
|
|
19
|
+
/minitest/,
|
|
20
|
+
/test-unit/,
|
|
21
|
+
/cucumber/,
|
|
22
|
+
/pry/,
|
|
23
|
+
/byebug/,
|
|
24
|
+
/debug/,
|
|
25
|
+
/<internal:/,
|
|
26
|
+
/kernel_require\.rb/
|
|
27
|
+
],
|
|
28
|
+
dedupe_frames: true,
|
|
29
|
+
include_gems_in_context: false
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
attr_reader :config
|
|
33
|
+
|
|
34
|
+
def initialize(config = {})
|
|
35
|
+
# Handle different config formats for backward compatibility
|
|
36
|
+
if config.respond_to?(:backtrace_context_lines)
|
|
37
|
+
# Configuration object passed
|
|
38
|
+
config_hash = {
|
|
39
|
+
context_lines: config.backtrace_context_lines,
|
|
40
|
+
max_frames: config.backtrace_max_frames,
|
|
41
|
+
enable_code_context: config.backtrace_enable_code_context,
|
|
42
|
+
in_app_patterns: config.backtrace_in_app_patterns,
|
|
43
|
+
exclude_patterns: config.backtrace_exclude_patterns,
|
|
44
|
+
strip_load_path: config.backtrace_strip_load_path
|
|
45
|
+
}
|
|
46
|
+
else
|
|
47
|
+
# Hash passed
|
|
48
|
+
config_hash = config
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@config = DEFAULT_CONFIG.merge(config_hash)
|
|
52
|
+
@file_cache = ActiveSupport::Cache::MemoryStore.new(
|
|
53
|
+
size: (@config[:file_cache_size] || 100) * 1024 * 1024, # Convert to bytes
|
|
54
|
+
expires_in: 1.hour
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def process_backtrace(backtrace)
|
|
59
|
+
return [] unless backtrace&.any?
|
|
60
|
+
|
|
61
|
+
# Parse all frames
|
|
62
|
+
frames = parse_frames(backtrace)
|
|
63
|
+
|
|
64
|
+
# Apply filtering
|
|
65
|
+
frames = filter_frames(frames)
|
|
66
|
+
|
|
67
|
+
# Limit frame count
|
|
68
|
+
frames = limit_frames(frames)
|
|
69
|
+
|
|
70
|
+
# Add code context if enabled
|
|
71
|
+
add_code_context(frames) if @config[:enable_code_context]
|
|
72
|
+
|
|
73
|
+
# Deduplicate if enabled
|
|
74
|
+
frames = dedupe_frames(frames) if @config[:dedupe_frames]
|
|
75
|
+
|
|
76
|
+
frames
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Backward compatibility alias
|
|
80
|
+
alias_method :process, :process_backtrace
|
|
81
|
+
|
|
82
|
+
def process_exception_backtrace(exception, follow_cause: false)
|
|
83
|
+
return [] unless exception&.backtrace
|
|
84
|
+
|
|
85
|
+
frames = process_backtrace(exception.backtrace)
|
|
86
|
+
|
|
87
|
+
# Add exception-specific context
|
|
88
|
+
frames.each_with_index do |frame, index|
|
|
89
|
+
frame.define_singleton_method(:crash_frame?) { index == 0 }
|
|
90
|
+
frame.define_singleton_method(:exception_class) { exception.class.name }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Follow exception causes if requested
|
|
94
|
+
if follow_cause && exception.respond_to?(:cause) && exception.cause
|
|
95
|
+
cause_frames = process_exception_backtrace(exception.cause, follow_cause: true)
|
|
96
|
+
frames.concat(cause_frames)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
frames
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Backward compatibility aliases
|
|
103
|
+
alias_method :process_exception, :process_exception_backtrace
|
|
104
|
+
|
|
105
|
+
def clear_cache!
|
|
106
|
+
@file_cache.clear
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def to_hash_array(frames)
|
|
110
|
+
frames.map(&:to_h)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def format_frames(frames, format = :sentry)
|
|
114
|
+
case format
|
|
115
|
+
when :sentry
|
|
116
|
+
to_sentry_format(frames)
|
|
117
|
+
when :rollbar
|
|
118
|
+
to_rollbar_format(frames)
|
|
119
|
+
when :bugsnag
|
|
120
|
+
to_bugsnag_format(frames)
|
|
121
|
+
else
|
|
122
|
+
to_sentry_format(frames)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def to_sentry_format(frames)
|
|
127
|
+
frames.map do |frame|
|
|
128
|
+
data = {
|
|
129
|
+
filename: frame.filename,
|
|
130
|
+
lineno: frame.line_number,
|
|
131
|
+
function: frame.function || frame.method_name,
|
|
132
|
+
module: frame.module_name,
|
|
133
|
+
in_app: frame.in_app
|
|
134
|
+
}.compact
|
|
135
|
+
|
|
136
|
+
# Add code context for Sentry
|
|
137
|
+
if frame.code_context
|
|
138
|
+
data[:pre_context] = frame.code_context[:pre_context]
|
|
139
|
+
data[:context_line] = frame.code_context[:context_line]
|
|
140
|
+
data[:post_context] = frame.code_context[:post_context]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
data
|
|
144
|
+
end.reverse # Sentry expects oldest frame first
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def to_rollbar_format(frames)
|
|
148
|
+
frames.map do |frame|
|
|
149
|
+
{
|
|
150
|
+
filename: frame.filename,
|
|
151
|
+
lineno: frame.line_number,
|
|
152
|
+
method: frame.function || frame.method_name,
|
|
153
|
+
code: frame.code_context&.dig(:context_line)
|
|
154
|
+
}.compact
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def to_bugsnag_format(frames)
|
|
159
|
+
frames.map do |frame|
|
|
160
|
+
data = {
|
|
161
|
+
file: frame.filename,
|
|
162
|
+
lineNumber: frame.line_number,
|
|
163
|
+
method: frame.function || frame.method_name,
|
|
164
|
+
inProject: frame.in_app
|
|
165
|
+
}.compact
|
|
166
|
+
|
|
167
|
+
# Add code context for Bugsnag/Insight Hub
|
|
168
|
+
if frame.code_context
|
|
169
|
+
data[:code] = {
|
|
170
|
+
frame.code_context[:line_number] => frame.code_context[:context_line]
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
data
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def stats
|
|
179
|
+
{
|
|
180
|
+
file_cache: {
|
|
181
|
+
# ActiveSupport::Cache::MemoryStore doesn't expose detailed stats
|
|
182
|
+
# but we can provide basic info
|
|
183
|
+
type: "ActiveSupport::Cache::MemoryStore",
|
|
184
|
+
configured_size: @file_cache.options[:size]
|
|
185
|
+
},
|
|
186
|
+
config: @config,
|
|
187
|
+
load_paths: @load_paths
|
|
188
|
+
}
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def clear_cache
|
|
192
|
+
@file_cache.clear
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Get code context around a specific line number using ActiveSupport::Cache
|
|
196
|
+
def get_code_context(filename, line_number, context_lines = 3)
|
|
197
|
+
return nil unless filename && File.exist?(filename)
|
|
198
|
+
return nil if File.size(filename) > (@config[:max_file_size] || (1024 * 1024))
|
|
199
|
+
|
|
200
|
+
lines = @file_cache.fetch(filename) do
|
|
201
|
+
read_file_safely(filename)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
return nil unless lines
|
|
205
|
+
|
|
206
|
+
# Convert to 0-based index
|
|
207
|
+
line_index = line_number - 1
|
|
208
|
+
return nil if line_index < 0 || line_index >= lines.length
|
|
209
|
+
|
|
210
|
+
# Calculate context range
|
|
211
|
+
start_line = [0, line_index - context_lines].max
|
|
212
|
+
end_line = [lines.length - 1, line_index + context_lines].min
|
|
213
|
+
|
|
214
|
+
{
|
|
215
|
+
pre_context: lines[start_line...line_index],
|
|
216
|
+
context_line: lines[line_index],
|
|
217
|
+
post_context: lines[(line_index + 1)..end_line],
|
|
218
|
+
line_number: line_number,
|
|
219
|
+
start_line: start_line + 1,
|
|
220
|
+
end_line: end_line + 1
|
|
221
|
+
}
|
|
222
|
+
rescue StandardError
|
|
223
|
+
# Return nil on any file read error
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
def read_file_safely(filename)
|
|
230
|
+
File.readlines(filename, chomp: true)
|
|
231
|
+
rescue StandardError
|
|
232
|
+
[]
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def parse_frames(backtrace)
|
|
236
|
+
load_paths = determine_load_paths
|
|
237
|
+
frames = backtrace.map do |line|
|
|
238
|
+
BacktraceFrame.new(
|
|
239
|
+
line,
|
|
240
|
+
in_app_patterns: @config[:in_app_patterns],
|
|
241
|
+
exclude_patterns: @config[:exclude_patterns],
|
|
242
|
+
load_paths: load_paths
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Filter out invalid frames
|
|
247
|
+
frames.select(&:valid?)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def filter_frames(frames)
|
|
251
|
+
# Remove excluded frames
|
|
252
|
+
frames = frames.reject(&:excluded?)
|
|
253
|
+
|
|
254
|
+
# Filter gems if configured
|
|
255
|
+
unless @config[:include_gems_in_context]
|
|
256
|
+
# Keep app frames and a limited number of library frames for context, but preserve order
|
|
257
|
+
library_count = 0
|
|
258
|
+
frames = frames.select do |frame|
|
|
259
|
+
if frame.app_frame?
|
|
260
|
+
true
|
|
261
|
+
elsif frame.library_frame? && library_count < 10
|
|
262
|
+
library_count += 1
|
|
263
|
+
true
|
|
264
|
+
else
|
|
265
|
+
false
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
frames
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def limit_frames(frames)
|
|
274
|
+
max_frames = @config[:max_frames]
|
|
275
|
+
return frames if frames.length <= max_frames
|
|
276
|
+
|
|
277
|
+
# Use head/tail splitting algorithm for better debugging context
|
|
278
|
+
# Keep 70% from the head (most recent frames) and 30% from the tail (original cause)
|
|
279
|
+
head_count = (max_frames * 0.7).round
|
|
280
|
+
tail_count = max_frames - head_count
|
|
281
|
+
|
|
282
|
+
# Get head frames (most recent)
|
|
283
|
+
head_frames = frames.first(head_count)
|
|
284
|
+
|
|
285
|
+
# Get tail frames (original cause)
|
|
286
|
+
tail_frames = if tail_count > 0
|
|
287
|
+
frames.last(tail_count)
|
|
288
|
+
else
|
|
289
|
+
[]
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
head_frames + tail_frames
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def add_code_context(frames)
|
|
296
|
+
# Only add context to app frames and a few library frames for performance
|
|
297
|
+
context_frames = frames.select(&:app_frame?)
|
|
298
|
+
context_frames += frames.select(&:library_frame?).first(3)
|
|
299
|
+
|
|
300
|
+
context_frames.each do |frame|
|
|
301
|
+
frame.add_code_context(self, @config[:context_lines])
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def dedupe_frames(frames)
|
|
306
|
+
seen = Set.new
|
|
307
|
+
frames.select do |frame|
|
|
308
|
+
# Create a key based on filename, line, and method
|
|
309
|
+
key = [frame.filename, frame.line_number, frame.function].join(':')
|
|
310
|
+
|
|
311
|
+
if seen.include?(key)
|
|
312
|
+
false
|
|
313
|
+
else
|
|
314
|
+
seen.add(key)
|
|
315
|
+
true
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def determine_load_paths
|
|
321
|
+
paths = []
|
|
322
|
+
|
|
323
|
+
# Add Ruby load paths
|
|
324
|
+
paths.concat($LOAD_PATH) if @config[:strip_load_path]
|
|
325
|
+
|
|
326
|
+
# Add common Rails paths if in Rails
|
|
327
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
328
|
+
paths << Rails.root.to_s
|
|
329
|
+
paths << Rails.root.join('app').to_s
|
|
330
|
+
paths << Rails.root.join('lib').to_s
|
|
331
|
+
paths << Rails.root.join('config').to_s
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Add current working directory
|
|
335
|
+
paths << Dir.pwd
|
|
336
|
+
|
|
337
|
+
# Add gem paths
|
|
338
|
+
if defined?(Gem)
|
|
339
|
+
paths.concat(Gem.path.map { |p| File.join(p, 'gems') })
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Sort by length (longest first) for better matching
|
|
343
|
+
paths.uniq.sort_by(&:length).reverse
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
require_relative "scope"
|
|
5
|
+
require_relative "current"
|
|
6
|
+
|
|
7
|
+
module Lapsoss
|
|
8
|
+
class Client
|
|
9
|
+
def initialize(configuration)
|
|
10
|
+
@configuration = configuration
|
|
11
|
+
@executor = Concurrent::FixedThreadPool.new(5) if @configuration.async
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def capture_exception(exception, **context)
|
|
15
|
+
return unless @configuration.enabled
|
|
16
|
+
|
|
17
|
+
with_scope(context) do |scope|
|
|
18
|
+
event = Event.new(
|
|
19
|
+
type: :exception,
|
|
20
|
+
level: :error,
|
|
21
|
+
exception: exception,
|
|
22
|
+
context: scope_to_context(scope)
|
|
23
|
+
)
|
|
24
|
+
capture_event(event)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def capture_message(message, level: :info, **context)
|
|
29
|
+
return unless @configuration.enabled
|
|
30
|
+
|
|
31
|
+
with_scope(context) do |scope|
|
|
32
|
+
event = Event.new(
|
|
33
|
+
type: :message,
|
|
34
|
+
level: level,
|
|
35
|
+
message: message,
|
|
36
|
+
context: scope_to_context(scope)
|
|
37
|
+
)
|
|
38
|
+
capture_event(event)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def add_breadcrumb(message, type: :default, **metadata)
|
|
43
|
+
current_scope.add_breadcrumb(message, type: type, **metadata)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def with_scope(context = {})
|
|
47
|
+
original_scope = current_scope
|
|
48
|
+
|
|
49
|
+
# Create a merged scope with the new context
|
|
50
|
+
merged_scope = MergedScope.new([context], original_scope)
|
|
51
|
+
Current.scope = merged_scope
|
|
52
|
+
|
|
53
|
+
yield(merged_scope)
|
|
54
|
+
ensure
|
|
55
|
+
Current.scope = original_scope
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def current_scope
|
|
59
|
+
Current.scope ||= Scope.new
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def flush(timeout: 2)
|
|
63
|
+
Registry.instance.flush(timeout: timeout)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def shutdown
|
|
67
|
+
@executor&.shutdown
|
|
68
|
+
Registry.instance.shutdown
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def capture_event(event)
|
|
76
|
+
# Apply pipeline processing if enabled
|
|
77
|
+
if @configuration.enable_pipeline && @configuration.pipeline
|
|
78
|
+
event = @configuration.pipeline.call(event)
|
|
79
|
+
return unless event
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
event = run_before_send(event)
|
|
83
|
+
return unless event
|
|
84
|
+
|
|
85
|
+
if @configuration.async
|
|
86
|
+
@executor.post { Router.process_event(event) }
|
|
87
|
+
else
|
|
88
|
+
Router.process_event(event)
|
|
89
|
+
end
|
|
90
|
+
rescue => e
|
|
91
|
+
handle_capture_error(e)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def run_before_send(event)
|
|
95
|
+
return event unless @configuration.before_send
|
|
96
|
+
|
|
97
|
+
@configuration.before_send.call(event)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def scope_to_context(scope)
|
|
101
|
+
{
|
|
102
|
+
tags: scope.tags,
|
|
103
|
+
user: scope.user,
|
|
104
|
+
extra: scope.extra,
|
|
105
|
+
breadcrumbs: scope.breadcrumbs
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def handle_capture_error(error)
|
|
110
|
+
return unless @configuration.logger
|
|
111
|
+
|
|
112
|
+
@configuration.logger.error("[Lapsoss] Failed to capture event: #{error.message}")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|