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,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "../http_client"
5
+ require_relative "../backtrace_processor"
6
+
7
+ module Lapsoss
8
+ module Adapters
9
+ class SentryAdapter < Base
10
+ PROTOCOL_VERSION = 7
11
+ CONTENT_TYPE = "application/x-sentry-envelope"
12
+ GZIP_THRESHOLD = 1024 * 30 # 30KB
13
+ USER_AGENT = "lapsoss/#{Lapsoss::VERSION}"
14
+
15
+ def initialize(name, settings = {})
16
+ super(name, settings)
17
+ validate_settings!
18
+ return unless settings[:dsn]
19
+
20
+ @dsn = parse_dsn(settings[:dsn])
21
+ @protocol_version = settings[:protocol_version] || PROTOCOL_VERSION
22
+ @client = create_http_client(sentry_api_uri)
23
+ @backtrace_processor = BacktraceProcessor.new
24
+ end
25
+
26
+ def capture(event)
27
+ return unless @client
28
+
29
+ envelope = build_envelope(event)
30
+ body, compressed = serialize_envelope(envelope)
31
+
32
+ headers = build_headers(compressed)
33
+
34
+ begin
35
+ @client.post(@dsn[:path], body: body, headers: headers)
36
+ rescue DeliveryError => e
37
+ # Log the error and potentially notify error handler
38
+ Lapsoss.configuration.logger&.error("[Lapsoss::SentryAdapter] Failed to deliver event: #{e.message}")
39
+ Lapsoss.configuration.error_handler&.call(e)
40
+
41
+ # Re-raise to let the caller know delivery failed
42
+ raise
43
+ end
44
+ end
45
+
46
+ def shutdown
47
+ @client&.shutdown
48
+ super
49
+ end
50
+
51
+ def capabilities
52
+ super.merge(
53
+ code_context: true,
54
+ breadcrumbs: true
55
+ )
56
+ end
57
+
58
+ private
59
+
60
+ def build_envelope(event)
61
+ # This structure is specific to the Sentry Envelope format
62
+ header = {
63
+ event_id: event.context[:event_id] || SecureRandom.uuid,
64
+ sent_at: Time.now.iso8601,
65
+ sdk: { name: "lapsoss", version: Lapsoss::VERSION }
66
+ }
67
+
68
+ item_type = event.type == :transaction ? "transaction" : "event"
69
+ item_header = { type: item_type, content_type: "application/json" }
70
+ item_payload = build_event_payload(event)
71
+
72
+ [header, item_header, item_payload]
73
+ end
74
+
75
+ def serialize_envelope(envelope)
76
+ header, item_header, item_payload = envelope
77
+
78
+ body = [
79
+ JSON.generate(header),
80
+ JSON.generate(item_header),
81
+ JSON.generate(item_payload)
82
+ ].join("\n")
83
+
84
+ if body.bytesize >= GZIP_THRESHOLD
85
+ [Zlib.gzip(body), true]
86
+ else
87
+ [body, false]
88
+ end
89
+ end
90
+
91
+ def build_event_payload(event)
92
+ {
93
+ platform: "ruby",
94
+ level: map_level(event.level),
95
+ timestamp: event.timestamp.to_f,
96
+ environment: @settings[:environment],
97
+ release: @settings[:release],
98
+ tags: event.context[:tags],
99
+ user: event.context[:user],
100
+ extra: event.context[:extra],
101
+ breadcrumbs: { values: event.context[:breadcrumbs] || [] }
102
+ }.merge(event_specific_payload(event))
103
+ end
104
+
105
+ def event_specific_payload(event)
106
+ case event.type
107
+ when :exception
108
+ {
109
+ exception: {
110
+ values: [{
111
+ type: event.exception.class.name,
112
+ value: event.exception.message,
113
+ stacktrace: { frames: parse_backtrace(event.exception.backtrace) }
114
+ }]
115
+ }
116
+ }
117
+ when :message
118
+ { message: event.message }
119
+ else
120
+ {}
121
+ end
122
+ end
123
+
124
+ def build_headers(compressed)
125
+ {
126
+ "Content-Type" => CONTENT_TYPE,
127
+ "X-Sentry-Auth" => auth_header,
128
+ "Content-Encoding" => ("gzip" if compressed)
129
+ }.compact
130
+ end
131
+
132
+ def auth_header
133
+ timestamp = Time.now.to_i
134
+ "Sentry sentry_version=#{@protocol_version}, sentry_client=#{USER_AGENT}, sentry_timestamp=#{timestamp}, sentry_key=#{@dsn[:public_key]}"
135
+ end
136
+
137
+ def parse_dsn(dsn_string)
138
+ uri = URI.parse(dsn_string)
139
+
140
+ # Trust the DSN path as provided - don't try to reconstruct it
141
+ # The DSN should contain the exact endpoint path to use
142
+ # Examples:
143
+ # - Standard Sentry: https://public_key@sentry.io/1 -> /api/1/envelope/
144
+ # - Custom service: https://public_key@custom.com/api/v1/errors -> /api/v1/errors
145
+
146
+ # Extract project ID for auth header (usually the last path segment)
147
+ path_parts = uri.path.split("/").reject(&:empty?)
148
+ project_id = path_parts.last || "unknown"
149
+
150
+ # Use the DSN path directly - this is what the service expects
151
+ api_path = uri.path
152
+
153
+ # For standard Sentry DSNs (just /project_id), build the envelope path
154
+ if path_parts.length == 1 && project_id.match?(/^\d+$/)
155
+ api_path = "/api/#{project_id}/envelope/"
156
+ end
157
+
158
+ {
159
+ public_key: uri.user,
160
+ project_id: project_id,
161
+ path: api_path
162
+ }
163
+ end
164
+
165
+ def sentry_api_uri
166
+ uri = URI.parse(@settings[:dsn])
167
+ "#{uri.scheme}://#{uri.host}:#{uri.port}"
168
+ end
169
+
170
+ def parse_backtrace(backtrace)
171
+ frames = @backtrace_processor.process(backtrace)
172
+ formatted_frames = @backtrace_processor.format_frames(frames, :sentry)
173
+ # Sentry expects frames in reverse order (most recent first)
174
+ formatted_frames.reverse
175
+ end
176
+
177
+ def map_level(level)
178
+ case level
179
+ when :debug then "debug"
180
+ when :info then "info"
181
+ when :warn, :warning then "warning"
182
+ when :error then "error"
183
+ when :fatal then "fatal"
184
+ else "info"
185
+ end
186
+ end
187
+
188
+ def validate_settings!
189
+ if @settings[:dsn]
190
+ validate_dsn!(@settings[:dsn], "Sentry DSN")
191
+ else
192
+ raise ValidationError, "Sentry DSN is required"
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
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
57
+ attr_reader :function, :module_name, :code_context, :block_info
58
+
59
+ # Backward compatibility aliases
60
+ alias_method :lineno, :line_number
61
+ alias_method :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
73
+
74
+ def to_h
75
+ {
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
84
+ }.compact
85
+ end
86
+
87
+ def add_code_context(processor, context_lines = 3)
88
+ return unless @filename && @line_number && File.exist?(@filename)
89
+
90
+ @code_context = processor.get_code_context(@filename, @line_number, context_lines)
91
+ end
92
+
93
+ def valid?
94
+ @filename && (@line_number.nil? || @line_number >= 0)
95
+ end
96
+
97
+ def library_frame?
98
+ !@in_app
99
+ end
100
+
101
+ def app_frame?
102
+ @in_app
103
+ end
104
+
105
+ def excluded?
106
+ return false if @exclude_patterns.empty?
107
+
108
+ @exclude_patterns.any? do |pattern|
109
+ case pattern
110
+ when Regexp
111
+ @raw_line.match?(pattern)
112
+ when String
113
+ @raw_line.include?(pattern)
114
+ else
115
+ false
116
+ end
117
+ end
118
+ end
119
+
120
+ def relative_filename
121
+ return @filename unless @filename && @load_paths.any?
122
+
123
+ # 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(/^#{Regexp.escape(load_path)}\/?/, '')
127
+ return relative unless relative.empty?
128
+ end
129
+ end
130
+
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
+ if match.names.include?('line') && match[:line]
144
+ @line_number = match[:line].match?(/^\d+$/) ? match[:line].to_i : 0
145
+ else
146
+ @line_number = nil
147
+ end
148
+ @method_name = match.names.include?('method') ? match[:method] : nil
149
+ @native_gem = match.names.include?('native_gem') ? match[:native_gem] : nil
150
+ @block_level = match.names.include?('block_level') ? match[:block_level] : nil
151
+
152
+ # Set default method name for lines without methods (top-level execution)
153
+ @method_name = "<main>" if @method_name.nil?
154
+
155
+ process_method_info
156
+ return
157
+ end
158
+
159
+ # Fallback: treat entire line as filename if no pattern matches
160
+ @filename = @raw_line
161
+ @line_number = nil
162
+ @method_name = "<main>"
163
+ end
164
+
165
+ def process_method_info
166
+ return unless @method_name
167
+
168
+ # Extract module/class and method information
169
+ if @method_name.include?('.')
170
+ # Class method: Module.method
171
+ parts = @method_name.split('.', 2)
172
+ @module_name = parts[0] if parts[0] != @method_name
173
+ @function = parts[1] || @method_name
174
+ elsif @method_name.include?('#')
175
+ # Instance method: Module#method
176
+ parts = @method_name.split('#', 2)
177
+ @module_name = parts[0] if parts[0] != @method_name
178
+ @function = parts[1] || @method_name
179
+ elsif @method_name.start_with?('block')
180
+ # Block method: process specially
181
+ @function = @method_name
182
+ process_block_info
183
+ else
184
+ @function = @method_name
185
+ end
186
+
187
+ # Clean up function name
188
+ @function = @function&.strip
189
+ @module_name = @module_name&.strip
190
+ end
191
+
192
+ def process_block_info
193
+ return unless @method_name&.include?('block')
194
+
195
+ @block_info = {
196
+ type: :block,
197
+ level: @block_level,
198
+ in_method: nil
199
+ }
200
+
201
+ # Extract the method that contains the block
202
+ if @method_name.match(/block (?:\([^)]+\)\s+)?in (.+)/)
203
+ @block_info[:in_method] = $1
204
+ end
205
+ end
206
+
207
+ def determine_app_status
208
+ return @in_app = false unless @filename
209
+
210
+ # Check explicit patterns first
211
+ if @in_app_patterns.any?
212
+ @in_app = @in_app_patterns.any? do |pattern|
213
+ case pattern
214
+ when Regexp
215
+ @filename.match?(pattern)
216
+ when String
217
+ @filename.include?(pattern)
218
+ else
219
+ false
220
+ end
221
+ end
222
+ return
223
+ end
224
+
225
+ # Default heuristics: check for library indicators
226
+ @in_app = !LIBRARY_INDICATORS.any? { |indicator| @filename.include?(indicator) }
227
+
228
+ # Special cases
229
+ @in_app = false if @native_gem # Native extensions are not app code
230
+ @in_app = false if @filename.start_with?('(') && @filename.end_with?(')') # Eval, irb, etc.
231
+ end
232
+
233
+ def normalize_paths
234
+ return unless @filename
235
+
236
+ # Expand relative paths
237
+ if @filename.start_with?('./')
238
+ @filename = File.expand_path(@filename)
239
+ end
240
+
241
+ # Handle Windows paths on Unix systems (for cross-platform stack traces)
242
+ if @filename.include?('\\') && !@filename.include?('/')
243
+ @filename = @filename.tr('\\', '/')
244
+ end
245
+
246
+ # Strip load paths to make traces more readable
247
+ if @load_paths.any?
248
+ original = @filename
249
+ @filename = relative_filename
250
+
251
+ # Keep absolute path if relative didn't work well
252
+ if @filename.empty? || @filename == '.'
253
+ @filename = original
254
+ end
255
+ end
256
+ end
257
+ end
258
+ end