lapsoss 0.3.0 → 0.4.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.
@@ -1,41 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require "active_support/core_ext/object/blank"
4
4
  require "socket"
5
- require "securerandom"
6
5
 
7
6
  module Lapsoss
8
7
  module Adapters
9
8
  class RollbarAdapter < Base
10
- API_URI = "https://api.rollbar.com"
11
- API_VERSION = "1"
12
- JSON_CONTENT_TYPE = "application/json"
9
+ include Concerns::LevelMapping
10
+ include Concerns::EnvelopeBuilder
11
+ include Concerns::HttpDelivery
12
+
13
+ self.level_mapping_type = :rollbar
14
+ self.api_endpoint = "https://api.rollbar.com"
15
+ self.api_path = "/api/1/item/"
13
16
 
14
17
  def initialize(name, settings = {})
15
18
  super
16
- @access_token = settings[:access_token] || ENV.fetch("ROLLBAR_ACCESS_TOKEN", nil)
17
- @environment = settings[:environment] || Lapsoss.configuration.environment || "development"
18
-
19
- validate_settings!
19
+ @access_token = settings[:access_token].presence || ENV["ROLLBAR_ACCESS_TOKEN"]
20
20
 
21
- @client = create_http_client(API_URI)
22
- @backtrace_processor = BacktraceProcessor.new
21
+ if @access_token.blank?
22
+ Lapsoss.configuration.logger&.warn "[Lapsoss::RollbarAdapter] No access token provided, adapter disabled"
23
+ @enabled = false
24
+ else
25
+ validate_api_key!(@access_token, "Rollbar access token", format: :alphanumeric)
26
+ end
23
27
  end
24
28
 
25
29
  def capture(event)
26
- return unless enabled?
27
-
28
- payload = build_payload(event)
29
- return unless payload
30
-
31
- response = @client.post("/api/#{API_VERSION}/item/", body: payload.to_json, headers: {
32
- "Content-Type" => JSON_CONTENT_TYPE,
33
- "X-Rollbar-Access-Token" => @access_token
34
- })
35
-
36
- handle_response(response, event)
37
- rescue StandardError => e
38
- handle_delivery_error(e)
30
+ deliver(event.scrubbed)
39
31
  end
40
32
 
41
33
  def capabilities
@@ -47,149 +39,99 @@ module Lapsoss
47
39
  )
48
40
  end
49
41
 
50
- def validate!
51
- validate_settings!
52
- true
53
- end
54
-
55
42
  private
56
43
 
57
44
  def build_payload(event)
58
45
  {
59
46
  access_token: @access_token,
60
- data: {
61
- environment: @environment,
62
- body: build_body(event),
63
- level: map_level(event.level),
64
- timestamp: event.timestamp.to_i,
65
- code_version: event.context[:release]&.dig(:commit_sha),
66
- platform: "ruby",
67
- language: "ruby",
68
- framework: detect_framework,
69
- server: build_server_data,
70
- person: build_person_data(event),
71
- request: event.request_context,
72
- custom: event.context[:custom] || {}
73
- }
47
+ data: build_rollbar_data(event)
74
48
  }
75
49
  end
76
50
 
77
- def build_body(event)
51
+ def build_rollbar_data(event)
52
+ {
53
+ environment: event.environment.presence || @settings[:environment] || "production",
54
+ body: build_rollbar_body(event),
55
+ level: map_level(event.level),
56
+ timestamp: event.timestamp.to_i,
57
+ code_version: @settings[:release].presence || git_sha,
58
+ platform: "ruby",
59
+ language: "ruby",
60
+ framework: detect_framework,
61
+ server: {
62
+ host: Socket.gethostname,
63
+ root: Rails.root.to_s.presence || Dir.pwd,
64
+ branch: git_branch,
65
+ code_version: git_sha
66
+ }.compact_blank,
67
+ person: build_person_data(event),
68
+ request: event.request_context,
69
+ custom: event.extra
70
+ }.compact_blank
71
+ end
72
+
73
+ def build_rollbar_body(event)
78
74
  case event.type
79
- when :exception
75
+ in :exception if event.has_exception?
80
76
  {
81
77
  trace: {
82
- frames: build_backtrace_frames(event.exception),
78
+ frames: format_backtrace_frames(event),
83
79
  exception: {
84
80
  class: event.exception_type,
85
- message: event.message,
81
+ message: event.exception_message,
86
82
  description: event.exception.to_s
87
83
  }
88
84
  }
89
85
  }
90
- when :message
86
+ in :message
91
87
  {
92
88
  message: {
93
89
  body: event.message
94
90
  }
95
91
  }
92
+ else
93
+ { message: { body: event.message || "Unknown event" } }
96
94
  end
97
95
  end
98
96
 
99
- def build_backtrace_frames(exception)
100
- return [] unless exception
97
+ def format_backtrace_frames(event)
98
+ return [] unless event.has_backtrace?
101
99
 
102
- frames = @backtrace_processor.process_exception(exception, follow_cause: true)
103
- formatted_frames = @backtrace_processor.format_frames(frames, :rollbar)
104
- # Rollbar expects frames in reverse order (most recent first)
105
- formatted_frames.reverse
100
+ # Rollbar expects frames in reverse order
101
+ event.backtrace_frames.map do |frame|
102
+ {
103
+ filename: frame.filename,
104
+ lineno: frame.lineno,
105
+ method: frame.method_name,
106
+ code: frame.code_context && frame.code_context[:context_line]
107
+ }.compact_blank
108
+ end.reverse
106
109
  end
107
110
 
108
111
  def build_person_data(event)
109
- user = event.context[:user]
110
- return nil unless user
112
+ user = event.user_context
113
+ return nil if user.blank?
111
114
 
112
115
  {
113
116
  id: user[:id]&.to_s,
114
117
  username: user[:username],
115
118
  email: user[:email]
116
- }.compact
117
- end
118
-
119
- def build_server_data
120
- {
121
- host: Socket.gethostname,
122
- root: defined?(Rails) ? Rails.root.to_s : Dir.pwd,
123
- branch: git_branch,
124
- code_version: git_sha
125
- }.compact
126
- end
127
-
128
- def map_level(level)
129
- case level
130
- when :debug then "debug"
131
- when :info then "info"
132
- when :warning then "warning"
133
- when :error then "error"
134
- when :fatal then "critical"
135
- else "error"
136
- end
119
+ }.compact_blank.presence
137
120
  end
138
121
 
139
122
  def detect_framework
140
123
  return "rails" if defined?(Rails)
141
124
  return "sinatra" if defined?(Sinatra)
142
-
143
125
  "ruby"
144
126
  end
145
127
 
146
- def git_branch
147
- `git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip.presence
148
- rescue StandardError
149
- nil
150
- end
151
-
152
- def git_sha
153
- `git rev-parse HEAD 2>/dev/null`.strip.presence
154
- rescue StandardError
155
- nil
156
- end
157
-
158
- def handle_response(response, event)
159
- case response.code.to_i
160
- when 200
161
- true
162
- when 400
163
- handle_client_error(response, event)
164
- when 401
165
- raise DeliveryError.new("Unauthorized: Invalid access token", response: response)
166
- when 429
167
- raise DeliveryError.new("Rate limit exceeded", response: response)
168
- else
169
- raise DeliveryError.new("Unexpected response: #{response.code}", response: response)
170
- end
171
- end
172
-
173
- def handle_client_error(response, _event)
174
- body = begin
175
- JSON.parse(response.body)
176
- rescue
177
- {}
178
- end
179
- error_msg = body["message"] || "Bad request"
180
-
181
- raise DeliveryError.new("Client error: #{error_msg}", response: response)
128
+ def adapter_specific_headers
129
+ { "X-Rollbar-Access-Token" => @access_token }
182
130
  end
183
131
 
132
+ # No longer need strict validation
184
133
  def validate_settings!
185
- validate_presence!(@access_token, "Rollbar access token")
186
- validate_api_key!(@access_token, "Rollbar access token", format: :alphanumeric) if @access_token
187
- validate_environment!(@environment, "Rollbar environment") if @environment
188
- end
189
-
190
- def handle_delivery_error(error, response = nil)
191
- message = "Rollbar delivery failed: #{error.message}"
192
- raise DeliveryError.new(message, response: response, cause: error)
134
+ # Validation moved to initialize with logging
193
135
  end
194
136
  end
195
137
  end
@@ -1,190 +1,124 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
3
+ require "active_support/core_ext/object/blank"
4
+ require "uri"
4
5
 
5
6
  module Lapsoss
6
7
  module Adapters
7
8
  class SentryAdapter < Base
8
- PROTOCOL_VERSION = 7
9
- CONTENT_TYPE = "application/x-sentry-envelope"
10
- GZIP_THRESHOLD = 1024 * 30 # 30KB
11
- USER_AGENT = "lapsoss/#{Lapsoss::VERSION}".freeze
9
+ include Concerns::LevelMapping
10
+ include Concerns::EnvelopeBuilder
11
+ include Concerns::HttpDelivery
12
+
13
+ self.level_mapping_type = :sentry
14
+ self.envelope_format = :sentry_envelope
12
15
 
13
16
  def initialize(name, settings = {})
14
17
  super
15
- validate_settings!
16
- return unless settings[:dsn]
17
-
18
- @dsn = parse_dsn(settings[:dsn])
19
- @protocol_version = settings[:protocol_version] || PROTOCOL_VERSION
20
- @client = create_http_client(sentry_api_uri)
21
- @backtrace_processor = BacktraceProcessor.new
22
- end
23
-
24
- def capture(event)
25
- return unless @client
26
-
27
- envelope = build_envelope(event)
28
- body, compressed = serialize_envelope(envelope)
29
-
30
- headers = build_headers(compressed)
31
18
 
32
- begin
33
- @client.post(@dsn[:path], body: body, headers: headers)
34
- rescue DeliveryError => e
35
- # Log the error and potentially notify error handler
36
- Lapsoss.configuration.logger&.error("[Lapsoss::SentryAdapter] Failed to deliver event: #{e.message}")
37
- Lapsoss.configuration.error_handler&.call(e)
19
+ if settings[:dsn].blank?
20
+ Lapsoss.configuration.logger&.warn "[Lapsoss::SentryAdapter] No DSN provided, adapter disabled"
21
+ @enabled = false
22
+ return
23
+ end
38
24
 
39
- # Re-raise to let the caller know delivery failed
40
- raise
25
+ if validate_dsn!(settings[:dsn], "Sentry DSN")
26
+ @dsn = parse_dsn(settings[:dsn])
27
+ setup_endpoint
28
+ else
29
+ @enabled = false
41
30
  end
42
31
  end
43
32
 
44
- def shutdown
45
- @client&.shutdown
46
- super
33
+ def capture(event)
34
+ deliver(event.scrubbed)
47
35
  end
48
36
 
49
37
  def capabilities
50
38
  super.merge(
51
39
  code_context: true,
52
- breadcrumbs: true
40
+ breadcrumbs: true,
41
+ data_scrubbing: true
53
42
  )
54
43
  end
55
44
 
56
45
  private
57
46
 
58
- def build_envelope(event)
59
- # This structure is specific to the Sentry Envelope format
60
- header = {
61
- event_id: event.context[:event_id] || SecureRandom.uuid,
62
- sent_at: Time.now.iso8601,
63
- sdk: { name: "lapsoss", version: Lapsoss::VERSION }
64
- }
65
-
66
- item_type = event.type == :transaction ? "transaction" : "event"
67
- item_header = { type: item_type, content_type: "application/json" }
68
- item_payload = build_event_payload(event)
69
-
70
- [ header, item_header, item_payload ]
71
- end
72
-
73
- def serialize_envelope(envelope)
74
- header, item_header, item_payload = envelope
75
-
76
- body = [
77
- JSON.generate(header),
78
- JSON.generate(item_header),
79
- JSON.generate(item_payload)
80
- ].join("\n")
81
-
82
- if body.bytesize >= GZIP_THRESHOLD
83
- [ Zlib.gzip(body), true ]
84
- else
85
- [ body, false ]
86
- end
87
- end
88
-
89
- def build_event_payload(event)
90
- {
91
- platform: "ruby",
92
- level: map_level(event.level),
93
- timestamp: event.timestamp.to_f,
94
- environment: @settings[:environment],
95
- release: @settings[:release],
96
- tags: event.context[:tags],
97
- user: event.context[:user],
98
- extra: event.context[:extra],
99
- breadcrumbs: { values: event.context[:breadcrumbs] || [] }
100
- }.merge(event_specific_payload(event))
101
- end
102
-
103
- def event_specific_payload(event)
104
- case event.type
105
- when :exception
106
- {
107
- exception: {
108
- values: [ {
109
- type: event.exception.class.name,
110
- value: event.exception.message,
111
- stacktrace: { frames: parse_backtrace(event.exception.backtrace) }
112
- } ]
113
- }
114
- }
115
- when :message
116
- { message: event.message }
117
- else
118
- {}
119
- end
47
+ def setup_endpoint
48
+ uri = URI.parse(@settings[:dsn])
49
+ self.class.api_endpoint = "#{uri.scheme}://#{uri.host}:#{uri.port}"
50
+ self.class.api_path = build_api_path(uri)
120
51
  end
121
52
 
122
- def build_headers(compressed)
123
- {
124
- "Content-Type" => CONTENT_TYPE,
125
- "X-Sentry-Auth" => auth_header,
126
- "Content-Encoding" => ("gzip" if compressed)
127
- }.compact
128
- end
53
+ def build_api_path(uri)
54
+ # Extract project ID from DSN path
55
+ project_id = uri.path.split("/").last
129
56
 
130
- def auth_header
131
- timestamp = Time.now.to_i
132
- "Sentry sentry_version=#{@protocol_version}, sentry_client=#{USER_AGENT}, sentry_timestamp=#{timestamp}, sentry_key=#{@dsn[:public_key]}"
57
+ # Standard Sentry envelope endpoint
58
+ "/api/#{project_id}/envelope/"
133
59
  end
134
60
 
135
61
  def parse_dsn(dsn_string)
136
62
  uri = URI.parse(dsn_string)
63
+ {
64
+ public_key: uri.user,
65
+ project_id: uri.path.split("/").last
66
+ }
67
+ end
137
68
 
138
- # Trust the DSN path as provided - don't try to reconstruct it
139
- # The DSN should contain the exact endpoint path to use
140
- # Examples:
141
- # - Standard Sentry: https://public_key@sentry.io/1 -> /api/1/envelope/
142
- # - Custom service: https://public_key@custom.com/api/v1/errors -> /api/v1/errors
143
-
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"
69
+ # Build Sentry-specific payload format
70
+ def build_payload(event)
71
+ # Sentry uses envelope format with headers and items
72
+ envelope_header = {
73
+ event_id: event.fingerprint.presence || SecureRandom.uuid,
74
+ sent_at: format_timestamp(event.timestamp),
75
+ sdk: sdk_info
76
+ }
147
77
 
148
- # Use the DSN path directly - this is what the service expects
149
- api_path = uri.path
78
+ item_header = {
79
+ type: event.type == :transaction ? "transaction" : "event",
80
+ content_type: "application/json"
81
+ }
150
82
 
151
- # For standard Sentry DSNs (just /project_id), build the envelope path
152
- api_path = "/api/#{project_id}/envelope/" if path_parts.length == 1 && project_id.match?(/^\d+$/)
83
+ item_payload = build_envelope_wrapper(event)
153
84
 
154
- {
155
- public_key: uri.user,
156
- project_id: project_id,
157
- path: api_path
158
- }
85
+ # Sentry envelope is newline-delimited JSON
86
+ [
87
+ ActiveSupport::JSON.encode(envelope_header),
88
+ ActiveSupport::JSON.encode(item_header),
89
+ ActiveSupport::JSON.encode(item_payload)
90
+ ].join("\n")
159
91
  end
160
92
 
161
- def sentry_api_uri
162
- uri = URI.parse(@settings[:dsn])
163
- "#{uri.scheme}://#{uri.host}:#{uri.port}"
93
+ # Override serialization for Sentry's envelope format
94
+ def serialize_payload(envelope_string)
95
+ # Sentry envelopes are already formatted, just compress if needed
96
+ if envelope_string.bytesize >= compress_threshold
97
+ [ ActiveSupport::Gzip.compress(envelope_string), true ]
98
+ else
99
+ [ envelope_string, false ]
100
+ end
164
101
  end
165
102
 
166
- def parse_backtrace(backtrace)
167
- frames = @backtrace_processor.process(backtrace)
168
- formatted_frames = @backtrace_processor.format_frames(frames, :sentry)
169
- # Sentry expects frames in reverse order (most recent first)
170
- formatted_frames.reverse
103
+ def adapter_specific_headers
104
+ timestamp = Time.current.to_i
105
+ {
106
+ "X-Sentry-Auth" => [
107
+ "Sentry sentry_version=7",
108
+ "sentry_client=#{user_agent}",
109
+ "sentry_timestamp=#{timestamp}",
110
+ "sentry_key=#{@dsn[:public_key]}"
111
+ ].join(", ")
112
+ }
171
113
  end
172
114
 
173
- def map_level(level)
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"
181
- end
115
+ def build_delivery_headers(compressed: false, content_type: nil)
116
+ super(compressed: compressed, content_type: "application/x-sentry-envelope")
182
117
  end
183
118
 
119
+ # No longer need strict validation
184
120
  def validate_settings!
185
- raise ValidationError, "Sentry DSN is required" unless @settings[:dsn]
186
-
187
- validate_dsn!(@settings[:dsn], "Sentry DSN")
121
+ # Validation moved to initialize with logging
188
122
  end
189
123
  end
190
124
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
3
4
  require "active_support/cache"
4
5
  require "active_support/core_ext/numeric/time"
5
6
 
@@ -337,7 +338,7 @@ module Lapsoss
337
338
  paths << Dir.pwd
338
339
 
339
340
  # Add gem paths
340
- paths.concat(Gem.path.map { |p| File.join(p, "gems") }) if defined?(Gem)
341
+ paths.concat(Gem.path.map { File.join(_1, "gems") }) if defined?(Gem)
341
342
 
342
343
  # Sort by length (longest first) for better matching
343
344
  paths.uniq.sort_by(&:length).reverse
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Breadcrumb
5
+ module_function
6
+
7
+ # Canonical breadcrumb builder used by SDK
8
+ # message: String, type: Symbol, metadata: Hash, timestamp: Time (UTC)
9
+ def build(message, type: :default, metadata: {})
10
+ {
11
+ message: message.to_s,
12
+ type: type.to_sym,
13
+ metadata: metadata || {},
14
+ timestamp: Time.now.utc
15
+ }
16
+ end
17
+
18
+ # Normalize an incoming breadcrumb-like hash to the canonical structure
19
+ def normalize(crumb)
20
+ msg = crumb[:message] || crumb["message"]
21
+ type = crumb[:type] || crumb["type"] || :default
22
+ metadata = crumb[:metadata] || crumb["metadata"] || crumb[:data] || crumb["data"] || {}
23
+ ts = crumb[:timestamp] || crumb["timestamp"]
24
+ ts = ts.utc if ts.respond_to?(:utc)
25
+
26
+ {
27
+ message: msg.to_s,
28
+ type: type.to_sym,
29
+ metadata: metadata.is_a?(Hash) ? metadata : Hash(metadata),
30
+ timestamp: ts || Time.now.utc
31
+ }
32
+ end
33
+
34
+ # Adapter conversions
35
+ def for_sentry(crumbs)
36
+ crumbs.map do |c|
37
+ c = normalize(c)
38
+ {
39
+ timestamp: c[:timestamp].utc.iso8601,
40
+ message: c[:message],
41
+ type: c[:type].to_s,
42
+ data: c[:metadata]
43
+ }
44
+ end
45
+ end
46
+
47
+ def for_insight_hub(crumbs)
48
+ crumbs.map do |c|
49
+ c = normalize(c)
50
+ {
51
+ timestamp: c[:timestamp].utc.iso8601,
52
+ name: c[:message],
53
+ type: c[:type].to_s,
54
+ metaData: c[:metadata]
55
+ }
56
+ end
57
+ end
58
+ end
59
+ end
@@ -13,7 +13,7 @@ module Lapsoss
13
13
  return unless @configuration.enabled
14
14
 
15
15
  with_scope(context) do |scope|
16
- event = Event.new(
16
+ event = Event.build(
17
17
  type: :exception,
18
18
  level: :error,
19
19
  exception: exception,
@@ -27,7 +27,7 @@ module Lapsoss
27
27
  return unless @configuration.enabled
28
28
 
29
29
  with_scope(context) do |scope|
30
- event = Event.new(
30
+ event = Event.build(
31
31
  type: :message,
32
32
  level: level,
33
33
  message: message,
@@ -272,42 +272,46 @@ module Lapsoss
272
272
  @fingerprint_callback = value
273
273
  end
274
274
 
275
- # Configuration validation
275
+ # Configuration validation - just log warnings, don't fail
276
276
  def validate!
277
- validate_sample_rate!(@sample_rate, "sample_rate") if @sample_rate
277
+ # Check sample rate is between 0 and 1
278
+ if @sample_rate && (@sample_rate < 0 || @sample_rate > 1)
279
+ logger&.warn "[Lapsoss] sample_rate should be between 0 and 1, got #{@sample_rate}"
280
+ end
281
+
282
+ # Check callables
278
283
  validate_callable!(@before_send, "before_send")
279
284
  validate_callable!(@error_handler, "error_handler")
280
285
  validate_callable!(@fingerprint_callback, "fingerprint_callback")
286
+
287
+ # Log if environment looks unusual
281
288
  validate_environment!(@environment, "environment") if @environment
282
289
 
283
- # Validate transport settings
284
- validate_timeout!(@transport_timeout, "transport_timeout")
285
- validate_retries!(@transport_max_retries, "transport_max_retries")
286
- validate_timeout!(@transport_initial_backoff, "transport_initial_backoff")
287
- validate_timeout!(@transport_max_backoff, "transport_max_backoff")
290
+ # Just log if transport settings look unusual
291
+ if @transport_timeout && @transport_timeout <= 0
292
+ logger&.warn "[Lapsoss] transport_timeout should be positive, got #{@transport_timeout}"
293
+ end
288
294
 
289
- if @transport_backoff_multiplier
290
- validate_type!(@transport_backoff_multiplier, [ Numeric ], "transport_backoff_multiplier")
291
- validate_numeric_range!(@transport_backoff_multiplier, 1.0..10.0, "transport_backoff_multiplier")
295
+ if @transport_max_retries && @transport_max_retries < 0
296
+ logger&.warn "[Lapsoss] transport_max_retries should be non-negative, got #{@transport_max_retries}"
292
297
  end
293
298
 
294
- # Validate that initial backoff is less than max backoff
295
299
  if @transport_initial_backoff && @transport_max_backoff && @transport_initial_backoff > @transport_max_backoff
296
- raise ValidationError,
297
- "transport_initial_backoff (#{@transport_initial_backoff}) must be less than transport_max_backoff (#{@transport_max_backoff})"
300
+ logger&.warn "[Lapsoss] transport_initial_backoff (#{@transport_initial_backoff}) should be less than transport_max_backoff (#{@transport_max_backoff})"
298
301
  end
299
302
 
300
- # Validate adapter configurations
303
+ # Validate adapter configurations exist
301
304
  @adapter_configs.each do |name, config|
302
- validate_adapter_config!(name, config)
305
+ if config[:type].blank?
306
+ logger&.warn "[Lapsoss] Adapter '#{name}' has no type specified"
307
+ end
303
308
  end
309
+
310
+ true
304
311
  end
305
312
 
306
313
  private
307
314
 
308
- def validate_adapter_config!(name, config)
309
- validate_presence!(config[:type], "adapter type for '#{name}'")
310
- validate_type!(config[:settings], [ Hash ], "adapter settings for '#{name}'")
311
- end
315
+ # Adapter config validation moved to inline logging
312
316
  end
313
317
  end