lapsoss 0.2.0 → 0.3.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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +153 -733
  3. data/lib/lapsoss/adapters/appsignal_adapter.rb +22 -22
  4. data/lib/lapsoss/adapters/base.rb +0 -3
  5. data/lib/lapsoss/adapters/insight_hub_adapter.rb +108 -104
  6. data/lib/lapsoss/adapters/logger_adapter.rb +1 -1
  7. data/lib/lapsoss/adapters/rollbar_adapter.rb +108 -68
  8. data/lib/lapsoss/adapters/sentry_adapter.rb +24 -24
  9. data/lib/lapsoss/backtrace_frame.rb +37 -206
  10. data/lib/lapsoss/backtrace_frame_factory.rb +228 -0
  11. data/lib/lapsoss/backtrace_processor.rb +26 -23
  12. data/lib/lapsoss/client.rb +2 -4
  13. data/lib/lapsoss/configuration.rb +28 -32
  14. data/lib/lapsoss/current.rb +10 -2
  15. data/lib/lapsoss/event.rb +28 -5
  16. data/lib/lapsoss/exception_backtrace_frame.rb +39 -0
  17. data/lib/lapsoss/exclusion_configuration.rb +30 -0
  18. data/lib/lapsoss/exclusion_filter.rb +0 -273
  19. data/lib/lapsoss/exclusion_presets.rb +249 -0
  20. data/lib/lapsoss/fingerprinter.rb +28 -28
  21. data/lib/lapsoss/http_client.rb +8 -8
  22. data/lib/lapsoss/merged_scope.rb +63 -0
  23. data/lib/lapsoss/middleware/base.rb +15 -0
  24. data/lib/lapsoss/middleware/conditional_filter.rb +18 -0
  25. data/lib/lapsoss/middleware/event_enricher.rb +19 -0
  26. data/lib/lapsoss/middleware/event_transformer.rb +19 -0
  27. data/lib/lapsoss/middleware/exception_filter.rb +43 -0
  28. data/lib/lapsoss/middleware/metrics_collector.rb +44 -0
  29. data/lib/lapsoss/middleware/rate_limiter.rb +31 -0
  30. data/lib/lapsoss/middleware/release_tracker.rb +117 -0
  31. data/lib/lapsoss/middleware/sample_filter.rb +23 -0
  32. data/lib/lapsoss/middleware/sampling_middleware.rb +18 -0
  33. data/lib/lapsoss/middleware/user_context_enhancer.rb +46 -0
  34. data/lib/lapsoss/middleware.rb +0 -339
  35. data/lib/lapsoss/pipeline.rb +0 -68
  36. data/lib/lapsoss/pipeline_builder.rb +69 -0
  37. data/lib/lapsoss/rails_error_subscriber.rb +42 -0
  38. data/lib/lapsoss/rails_middleware.rb +78 -0
  39. data/lib/lapsoss/railtie.rb +22 -50
  40. data/lib/lapsoss/registry.rb +18 -5
  41. data/lib/lapsoss/release_providers.rb +110 -0
  42. data/lib/lapsoss/release_tracker.rb +159 -232
  43. data/lib/lapsoss/sampling/adaptive_sampler.rb +46 -0
  44. data/lib/lapsoss/sampling/base.rb +11 -0
  45. data/lib/lapsoss/sampling/composite_sampler.rb +26 -0
  46. data/lib/lapsoss/sampling/consistent_hash_sampler.rb +30 -0
  47. data/lib/lapsoss/sampling/exception_type_sampler.rb +44 -0
  48. data/lib/lapsoss/sampling/health_based_sampler.rb +19 -0
  49. data/lib/lapsoss/sampling/rate_limiter.rb +32 -0
  50. data/lib/lapsoss/sampling/sampling_factory.rb +69 -0
  51. data/lib/lapsoss/sampling/time_based_sampler.rb +44 -0
  52. data/lib/lapsoss/sampling/uniform_sampler.rb +15 -0
  53. data/lib/lapsoss/sampling/user_based_sampler.rb +42 -0
  54. data/lib/lapsoss/sampling.rb +0 -322
  55. data/lib/lapsoss/scope.rb +12 -48
  56. data/lib/lapsoss/scrubber.rb +7 -7
  57. data/lib/lapsoss/user_context.rb +30 -203
  58. data/lib/lapsoss/user_context_integrations.rb +39 -0
  59. data/lib/lapsoss/user_context_middleware.rb +50 -0
  60. data/lib/lapsoss/user_context_provider.rb +93 -0
  61. data/lib/lapsoss/utils.rb +13 -0
  62. data/lib/lapsoss/validators.rb +15 -15
  63. data/lib/lapsoss/version.rb +1 -1
  64. data/lib/lapsoss.rb +3 -3
  65. metadata +54 -5
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'securerandom'
3
+ require "securerandom"
4
4
 
5
5
  module Lapsoss
6
6
  module Adapters
7
7
  class SentryAdapter < Base
8
8
  PROTOCOL_VERSION = 7
9
- CONTENT_TYPE = 'application/x-sentry-envelope'
9
+ CONTENT_TYPE = "application/x-sentry-envelope"
10
10
  GZIP_THRESHOLD = 1024 * 30 # 30KB
11
11
  USER_AGENT = "lapsoss/#{Lapsoss::VERSION}".freeze
12
12
 
@@ -60,14 +60,14 @@ module Lapsoss
60
60
  header = {
61
61
  event_id: event.context[:event_id] || SecureRandom.uuid,
62
62
  sent_at: Time.now.iso8601,
63
- sdk: { name: 'lapsoss', version: Lapsoss::VERSION }
63
+ sdk: { name: "lapsoss", version: Lapsoss::VERSION }
64
64
  }
65
65
 
66
- item_type = event.type == :transaction ? 'transaction' : 'event'
67
- item_header = { type: item_type, content_type: 'application/json' }
66
+ item_type = event.type == :transaction ? "transaction" : "event"
67
+ item_header = { type: item_type, content_type: "application/json" }
68
68
  item_payload = build_event_payload(event)
69
69
 
70
- [header, item_header, item_payload]
70
+ [ header, item_header, item_payload ]
71
71
  end
72
72
 
73
73
  def serialize_envelope(envelope)
@@ -80,15 +80,15 @@ module Lapsoss
80
80
  ].join("\n")
81
81
 
82
82
  if body.bytesize >= GZIP_THRESHOLD
83
- [Zlib.gzip(body), true]
83
+ [ Zlib.gzip(body), true ]
84
84
  else
85
- [body, false]
85
+ [ body, false ]
86
86
  end
87
87
  end
88
88
 
89
89
  def build_event_payload(event)
90
90
  {
91
- platform: 'ruby',
91
+ platform: "ruby",
92
92
  level: map_level(event.level),
93
93
  timestamp: event.timestamp.to_f,
94
94
  environment: @settings[:environment],
@@ -105,11 +105,11 @@ module Lapsoss
105
105
  when :exception
106
106
  {
107
107
  exception: {
108
- values: [{
108
+ values: [ {
109
109
  type: event.exception.class.name,
110
110
  value: event.exception.message,
111
111
  stacktrace: { frames: parse_backtrace(event.exception.backtrace) }
112
- }]
112
+ } ]
113
113
  }
114
114
  }
115
115
  when :message
@@ -121,9 +121,9 @@ module Lapsoss
121
121
 
122
122
  def build_headers(compressed)
123
123
  {
124
- 'Content-Type' => CONTENT_TYPE,
125
- 'X-Sentry-Auth' => auth_header,
126
- 'Content-Encoding' => ('gzip' if compressed)
124
+ "Content-Type" => CONTENT_TYPE,
125
+ "X-Sentry-Auth" => auth_header,
126
+ "Content-Encoding" => ("gzip" if compressed)
127
127
  }.compact
128
128
  end
129
129
 
@@ -142,8 +142,8 @@ module Lapsoss
142
142
  # - Custom service: https://public_key@custom.com/api/v1/errors -> /api/v1/errors
143
143
 
144
144
  # Extract project ID for auth header (usually the last path segment)
145
- path_parts = uri.path.split('/').reject(&:empty?)
146
- project_id = path_parts.last || 'unknown'
145
+ path_parts = uri.path.split("/").reject(&:empty?)
146
+ project_id = path_parts.last || "unknown"
147
147
 
148
148
  # Use the DSN path directly - this is what the service expects
149
149
  api_path = uri.path
@@ -172,19 +172,19 @@ module Lapsoss
172
172
 
173
173
  def map_level(level)
174
174
  case level
175
- when :debug then 'debug'
176
- when :info then 'info'
177
- when :warn, :warning then 'warning'
178
- when :error then 'error'
179
- when :fatal then 'fatal'
180
- else 'info'
175
+ when :debug then "debug"
176
+ when :info then "info"
177
+ when :warn, :warning then "warning"
178
+ when :error then "error"
179
+ when :fatal then "fatal"
180
+ else "info"
181
181
  end
182
182
  end
183
183
 
184
184
  def validate_settings!
185
- raise ValidationError, 'Sentry DSN is required' unless @settings[:dsn]
185
+ raise ValidationError, "Sentry DSN is required" unless @settings[:dsn]
186
186
 
187
- validate_dsn!(@settings[:dsn], 'Sentry DSN')
187
+ validate_dsn!(@settings[:dsn], "Sentry DSN")
188
188
  end
189
189
  end
190
190
  end
@@ -1,248 +1,79 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lapsoss
4
- class BacktraceFrame
5
- # Backtrace line patterns for different Ruby implementations
6
- BACKTRACE_PATTERNS = [
7
- # Standard Ruby format: filename.rb:123:in `method_name'
8
- /^(?<filename>[^:]+):(?<line>\d+):in [`'](?<method>.*?)[`']$/,
9
-
10
- # Ruby format without method: filename.rb:123
11
- /^(?<filename>[^:]+):(?<line>\d+)$/,
12
-
13
- # JRuby format: filename.rb:123:in method_name
14
- /^(?<filename>[^:]+):(?<line>\d+):in (?<method>.*)$/,
15
-
16
- # Eval'd code: (eval):123:in `method_name'
17
- /^\(eval\):(?<line>\d+):in [`'](?<method>.*?)[`']$/,
18
-
19
- # Block format: filename.rb:123:in `block in method_name'
20
- /^(?<filename>[^:]+):(?<line>\d+):in [`']block (?<block_level>\(\d+\s+levels\)\s+)?in (?<method>.*?)[`']$/,
21
-
22
- # Native extension format: [native_gem] filename.c:123:in `method_name'
23
- /^\[(?<native_gem>[^\]]+)\]\s*(?<filename>[^:]+):(?<line>\d+):in [`'](?<method>.*?)[`']$/,
24
-
25
- # Java backtrace format: org.jruby.Ruby.runScript(Ruby.java:123)
26
- /^(?<method>[^(]+)\((?<filename>[^:)]+):(?<line>\d+)\)$/,
27
-
28
- # Java backtrace format without line number: org.jruby.Ruby.runScript(Ruby.java)
29
- /^(?<method>[^(]+)\((?<filename>[^:)]+)\)$/,
30
-
31
- # Malformed Ruby format with invalid line number: filename.rb:abc:in `method'
32
- /^(?<filename>[^:]+):(?<line>[^:]*):in [`'](?<method>.*?)[`']$/,
33
-
34
- # Malformed Ruby format with missing line number: filename.rb::in `method'
35
- /^(?<filename>[^:]+)::in [`'](?<method>.*?)[`']$/,
36
-
37
- # Malformed Ruby format with missing method: filename.rb:123:in
38
- /^(?<filename>[^:]+):(?<line>\d+):in$/
39
- ].freeze
40
-
41
- # Common paths that indicate library/gem code
42
- LIBRARY_INDICATORS = [
43
- '/gems/',
44
- '/.bundle/',
45
- '/vendor/',
46
- '/ruby/',
47
- '(eval)',
48
- '(irb)',
49
- '/lib/ruby/',
50
- '/rbenv/',
51
- '/rvm/',
52
- '/usr/lib/ruby',
53
- '/System/Library/Frameworks'
54
- ].freeze
55
-
56
- attr_reader :filename, :line_number, :method_name, :in_app, :raw_line, :function, :module_name, :code_context,
57
- :block_info
58
-
4
+ BacktraceFrame = Data.define(
5
+ :filename,
6
+ :line_number,
7
+ :method_name,
8
+ :in_app,
9
+ :raw_line,
10
+ :function,
11
+ :module_name,
12
+ :code_context,
13
+ :block_info
14
+ ) do
59
15
  # Backward compatibility aliases
60
- alias lineno line_number
61
- alias raw raw_line
62
-
63
- def initialize(raw_line, in_app_patterns: [], exclude_patterns: [], load_paths: [])
64
- @raw_line = raw_line.to_s.strip
65
- @in_app_patterns = Array(in_app_patterns)
66
- @exclude_patterns = Array(exclude_patterns)
67
- @load_paths = Array(load_paths)
68
-
69
- parse_backtrace_line
70
- determine_app_status
71
- normalize_paths
72
- end
16
+ alias_method :lineno, :line_number
17
+ alias_method :raw, :raw_line
73
18
 
74
19
  def to_h
75
20
  {
76
- filename: @filename,
77
- line_number: @line_number,
78
- method: @method_name,
79
- function: @function,
80
- module: @module_name,
81
- in_app: @in_app,
82
- code_context: @code_context,
83
- raw: @raw_line
21
+ filename: filename,
22
+ line_number: line_number,
23
+ method: method_name,
24
+ function: function,
25
+ module: module_name,
26
+ in_app: in_app,
27
+ code_context: code_context,
28
+ raw: raw_line
84
29
  }.compact
85
30
  end
86
31
 
87
32
  def add_code_context(processor, context_lines = 3)
88
- return unless @filename && @line_number && File.exist?(@filename)
33
+ return unless filename && line_number && File.exist?(filename)
89
34
 
90
- @code_context = processor.get_code_context(@filename, @line_number, context_lines)
35
+ with(code_context: processor.get_code_context(filename, line_number, context_lines))
91
36
  end
92
37
 
93
38
  def valid?
94
- @filename && (@line_number.nil? || @line_number >= 0)
39
+ filename && (line_number.nil? || line_number >= 0)
95
40
  end
96
41
 
97
42
  def library_frame?
98
- !@in_app
43
+ !in_app
99
44
  end
100
45
 
101
46
  def app_frame?
102
- @in_app
47
+ in_app
103
48
  end
104
49
 
105
- def excluded?
106
- return false if @exclude_patterns.empty?
50
+ def excluded?(exclude_patterns = [])
51
+ return false if exclude_patterns.empty?
107
52
 
108
- @exclude_patterns.any? do |pattern|
53
+ exclude_patterns.any? do |pattern|
109
54
  case pattern
110
55
  when Regexp
111
- @raw_line.match?(pattern)
56
+ raw_line.match?(pattern)
112
57
  when String
113
- @raw_line.include?(pattern)
58
+ raw_line.include?(pattern)
114
59
  else
115
60
  false
116
61
  end
117
62
  end
118
63
  end
119
64
 
120
- def relative_filename
121
- return @filename unless @filename && @load_paths.any?
65
+ def relative_filename(load_paths = [])
66
+ return filename unless filename && load_paths.any?
122
67
 
123
68
  # Try to make path relative to load paths
124
- @load_paths.each do |load_path|
125
- if @filename.start_with?(load_path)
126
- relative = @filename.sub(%r{^#{Regexp.escape(load_path)}/?}, '')
69
+ load_paths.each do |load_path|
70
+ if filename.start_with?(load_path)
71
+ relative = filename.sub(%r{^#{Regexp.escape(load_path)}/?}, "")
127
72
  return relative unless relative.empty?
128
73
  end
129
74
  end
130
75
 
131
- @filename
132
- end
133
-
134
- private
135
-
136
- def parse_backtrace_line
137
- BACKTRACE_PATTERNS.each_with_index do |pattern, _pattern_index|
138
- match = @raw_line.match(pattern)
139
- next unless match
140
-
141
- @filename = match[:filename]
142
- # Handle malformed line numbers - convert invalid numbers to 0
143
- @line_number = if match.names.include?('line') && match[:line]
144
- match[:line].match?(/^\d+$/) ? match[:line].to_i : 0
145
- end
146
- @method_name = match.names.include?('method') ? match[:method] : nil
147
- @native_gem = match.names.include?('native_gem') ? match[:native_gem] : nil
148
- @block_level = match.names.include?('block_level') ? match[:block_level] : nil
149
-
150
- # Set default method name for lines without methods (top-level execution)
151
- @method_name = '<main>' if @method_name.nil?
152
-
153
- process_method_info
154
- return
155
- end
156
-
157
- # Fallback: treat entire line as filename if no pattern matches
158
- @filename = @raw_line
159
- @line_number = nil
160
- @method_name = '<main>'
161
- end
162
-
163
- def process_method_info
164
- return unless @method_name
165
-
166
- # Extract module/class and method information
167
- if @method_name.include?('.')
168
- # Class method: Module.method
169
- parts = @method_name.split('.', 2)
170
- @module_name = parts[0] if parts[0] != @method_name
171
- @function = parts[1] || @method_name
172
- elsif @method_name.include?('#')
173
- # Instance method: Module#method
174
- parts = @method_name.split('#', 2)
175
- @module_name = parts[0] if parts[0] != @method_name
176
- @function = parts[1] || @method_name
177
- elsif @method_name.start_with?('block')
178
- # Block method: process specially
179
- @function = @method_name
180
- process_block_info
181
- else
182
- @function = @method_name
183
- end
184
-
185
- # Clean up function name
186
- @function = @function&.strip
187
- @module_name = @module_name&.strip
188
- end
189
-
190
- def process_block_info
191
- return unless @method_name&.include?('block')
192
-
193
- @block_info = {
194
- type: :block,
195
- level: @block_level,
196
- in_method: nil
197
- }
198
-
199
- # Extract the method that contains the block
200
- @block_info[:in_method] = ::Regexp.last_match(1) if @method_name =~ /block (?:\([^)]+\)\s+)?in (.+)/
201
- end
202
-
203
- def determine_app_status
204
- return @in_app = false unless @filename
205
-
206
- # Check explicit patterns first
207
- if @in_app_patterns.any?
208
- @in_app = @in_app_patterns.any? do |pattern|
209
- case pattern
210
- when Regexp
211
- @filename.match?(pattern)
212
- when String
213
- @filename.include?(pattern)
214
- else
215
- false
216
- end
217
- end
218
- return
219
- end
220
-
221
- # Default heuristics: check for library indicators
222
- @in_app = LIBRARY_INDICATORS.none? { |indicator| @filename.include?(indicator) }
223
-
224
- # Special cases
225
- @in_app = false if @native_gem # Native extensions are not app code
226
- @in_app = false if @filename.start_with?('(') && @filename.end_with?(')') # Eval, irb, etc.
227
- end
228
-
229
- def normalize_paths
230
- return unless @filename
231
-
232
- # Expand relative paths
233
- @filename = File.expand_path(@filename) if @filename.start_with?('./')
234
-
235
- # Handle Windows paths on Unix systems (for cross-platform stack traces)
236
- @filename = @filename.tr('\\', '/') if @filename.include?('\\') && @filename.exclude?('/')
237
-
238
- # Strip load paths to make traces more readable
239
- return unless @load_paths.any?
240
-
241
- original = @filename
242
- @filename = relative_filename
243
-
244
- # Keep absolute path if relative didn't work well
245
- @filename = original if @filename.empty? || @filename == '.'
76
+ filename
246
77
  end
247
78
  end
248
79
  end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ class BacktraceFrameFactory
5
+ # Backtrace line patterns for different Ruby implementations
6
+ BACKTRACE_PATTERNS = [
7
+ # Standard Ruby format: filename.rb:123:in `method_name'
8
+ /^(?<filename>[^:]+):(?<line>\d+):in [`'](?<method>.*?)[`']$/,
9
+
10
+ # Ruby format without method: filename.rb:123
11
+ /^(?<filename>[^:]+):(?<line>\d+)$/,
12
+
13
+ # JRuby format: filename.rb:123:in method_name
14
+ /^(?<filename>[^:]+):(?<line>\d+):in (?<method>.*)$/,
15
+
16
+ # Eval'd code: (eval):123:in `method_name'
17
+ /^\(eval\):(?<line>\d+):in [`'](?<method>.*?)[`']$/,
18
+
19
+ # Block format: filename.rb:123:in `block in method_name'
20
+ /^(?<filename>[^:]+):(?<line>\d+):in [`']block (?<block_level>\(\d+\s+levels\)\s+)?in (?<method>.*?)[`']$/,
21
+
22
+ # Native extension format: [native_gem] filename.c:123:in `method_name'
23
+ /^\[(?<native_gem>[^\]]+)\]\s*(?<filename>[^:]+):(?<line>\d+):in [`'](?<method>.*?)[`']$/,
24
+
25
+ # Java backtrace format: org.jruby.Ruby.runScript(Ruby.java:123)
26
+ /^(?<method>[^(]+)\((?<filename>[^:)]+):(?<line>\d+)\)$/,
27
+
28
+ # Java backtrace format without line number: org.jruby.Ruby.runScript(Ruby.java)
29
+ /^(?<method>[^(]+)\((?<filename>[^:)]+)\)$/,
30
+
31
+ # Malformed Ruby format with invalid line number: filename.rb:abc:in `method'
32
+ /^(?<filename>[^:]+):(?<line>[^:]*):in [`'](?<method>.*?)[`']$/,
33
+
34
+ # Malformed Ruby format with missing line number: filename.rb::in `method'
35
+ /^(?<filename>[^:]+)::in [`'](?<method>.*?)[`']$/,
36
+
37
+ # Malformed Ruby format with missing method: filename.rb:123:in
38
+ /^(?<filename>[^:]+):(?<line>\d+):in$/
39
+ ].freeze
40
+
41
+ # Common paths that indicate library/gem code
42
+ LIBRARY_INDICATORS = [
43
+ "/gems/",
44
+ "/.bundle/",
45
+ "/vendor/",
46
+ "/ruby/",
47
+ "(eval)",
48
+ "(irb)",
49
+ "/lib/ruby/",
50
+ "/rbenv/",
51
+ "/rvm/",
52
+ "/usr/lib/ruby",
53
+ "/System/Library/Frameworks"
54
+ ].freeze
55
+
56
+ def self.from_raw_line(raw_line, in_app_patterns: [], exclude_patterns: [], load_paths: [])
57
+ new(in_app_patterns: in_app_patterns, exclude_patterns: exclude_patterns, load_paths: load_paths)
58
+ .create_frame(raw_line)
59
+ end
60
+
61
+ def initialize(in_app_patterns: [], exclude_patterns: [], load_paths: [])
62
+ @in_app_patterns = Array(in_app_patterns)
63
+ @exclude_patterns = Array(exclude_patterns)
64
+ @load_paths = Array(load_paths)
65
+ end
66
+
67
+ def create_frame(raw_line)
68
+ @raw_line = raw_line.to_s.strip
69
+ parse_backtrace_line
70
+ end
71
+
72
+ private
73
+
74
+ def parse_backtrace_line
75
+ filename, line_number, method_name, function, module_name, block_info = parse_line_components
76
+
77
+ in_app = determine_app_status(filename)
78
+ filename = normalize_path(filename) if filename
79
+
80
+ BacktraceFrame.new(
81
+ filename: filename,
82
+ line_number: line_number,
83
+ method_name: method_name,
84
+ in_app: in_app,
85
+ raw_line: @raw_line,
86
+ function: function,
87
+ module_name: module_name,
88
+ code_context: nil,
89
+ block_info: block_info
90
+ )
91
+ end
92
+
93
+ def parse_line_components
94
+ BACKTRACE_PATTERNS.each do |pattern|
95
+ match = @raw_line.match(pattern)
96
+ next unless match
97
+
98
+ filename = match[:filename]
99
+ # Handle malformed line numbers - convert invalid numbers to 0
100
+ line_number = if match.names.include?("line") && match[:line]
101
+ match[:line].match?(/^\d+$/) ? match[:line].to_i : 0
102
+ end
103
+ method_name = match.names.include?("method") ? match[:method] : nil
104
+ match.names.include?("native_gem") ? match[:native_gem] : nil
105
+ block_level = match.names.include?("block_level") ? match[:block_level] : nil
106
+
107
+ # Set default method name for lines without methods (top-level execution)
108
+ method_name = "<main>" if method_name.nil?
109
+
110
+ function, module_name, block_info = process_method_info(method_name, block_level)
111
+
112
+ return [ filename, line_number, method_name, function, module_name, block_info ]
113
+ end
114
+
115
+ # Fallback: treat entire line as filename if no pattern matches
116
+ [ @raw_line, nil, "<main>", "<main>", nil, nil ]
117
+ end
118
+
119
+ def process_method_info(method_name, block_level)
120
+ return [ nil, nil, nil ] unless method_name
121
+
122
+ function = nil
123
+ module_name = nil
124
+ block_info = nil
125
+
126
+ # Extract module/class and method information
127
+ if method_name.include?(".")
128
+ # Class method: Module.method
129
+ parts = method_name.split(".", 2)
130
+ module_name = parts[0] if parts[0] != method_name
131
+ function = parts[1] || method_name
132
+ elsif method_name.include?("#")
133
+ # Instance method: Module#method
134
+ parts = method_name.split("#", 2)
135
+ module_name = parts[0] if parts[0] != method_name
136
+ function = parts[1] || method_name
137
+ elsif method_name.start_with?("block")
138
+ # Block method: process specially
139
+ function = method_name
140
+ block_info = process_block_info(method_name, block_level)
141
+ else
142
+ function = method_name
143
+ end
144
+
145
+ # Clean up function name
146
+ function = function&.strip
147
+ module_name = module_name&.strip
148
+
149
+ [ function, module_name, block_info ]
150
+ end
151
+
152
+ def process_block_info(method_name, block_level)
153
+ return nil unless method_name&.include?("block")
154
+
155
+ block_info = {
156
+ type: :block,
157
+ level: block_level,
158
+ in_method: nil
159
+ }
160
+
161
+ # Extract the method that contains the block
162
+ block_info[:in_method] = ::Regexp.last_match(1) if method_name =~ /block (?:\([^)]+\)\s+)?in (.+)/
163
+
164
+ block_info
165
+ end
166
+
167
+ def determine_app_status(filename)
168
+ return false unless filename
169
+
170
+ # Check explicit patterns first
171
+ if @in_app_patterns.any?
172
+ return @in_app_patterns.any? do |pattern|
173
+ case pattern
174
+ when Regexp
175
+ filename.match?(pattern)
176
+ when String
177
+ filename.include?(pattern)
178
+ else
179
+ false
180
+ end
181
+ end
182
+ end
183
+
184
+ # Default heuristics: check for library indicators
185
+ in_app = LIBRARY_INDICATORS.none? { |indicator| filename.include?(indicator) }
186
+
187
+ # Special cases
188
+ in_app = false if filename.start_with?("(") && filename.end_with?(")") # Eval, irb, etc.
189
+
190
+ in_app
191
+ end
192
+
193
+ def normalize_path(filename)
194
+ return filename unless filename
195
+
196
+ # Expand relative paths
197
+ filename = File.expand_path(filename) if filename.start_with?("./")
198
+
199
+ # Handle Windows paths on Unix systems (for cross-platform stack traces)
200
+ filename = filename.tr("\\", "/") if filename.include?("\\") && filename.exclude?("/")
201
+
202
+ # Strip load paths to make traces more readable
203
+ return filename unless @load_paths.any?
204
+
205
+ original = filename
206
+ filename = make_relative_filename(filename)
207
+
208
+ # Keep absolute path if relative didn't work well
209
+ filename = original if filename.empty? || filename == "."
210
+
211
+ filename
212
+ end
213
+
214
+ def make_relative_filename(filename)
215
+ return filename unless filename && @load_paths.any?
216
+
217
+ # Try to make path relative to load paths
218
+ @load_paths.each do |load_path|
219
+ if filename.start_with?(load_path)
220
+ relative = filename.sub(%r{^#{Regexp.escape(load_path)}/?}, "")
221
+ return relative unless relative.empty?
222
+ end
223
+ end
224
+
225
+ filename
226
+ end
227
+ end
228
+ end