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