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,355 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ # Enhanced user context handling with privacy controls
5
+ class UserContext
6
+ SENSITIVE_FIELDS = %i[
7
+ email phone mobile telephone
8
+ address street city state zip postal_code country
9
+ ssn social_security_number
10
+ credit_card card_number cvv
11
+ password password_confirmation
12
+ secret token api_key
13
+ ip_address last_login_ip
14
+ birth_date date_of_birth dob
15
+ salary income wage
16
+ ].freeze
17
+
18
+ def initialize(privacy_mode: false, allowed_fields: nil, field_transformers: {})
19
+ @privacy_mode = privacy_mode
20
+ @allowed_fields = allowed_fields&.map(&:to_sym)
21
+ @field_transformers = field_transformers
22
+ end
23
+
24
+ def process_user_data(user_data)
25
+ return {} unless user_data.is_a?(Hash)
26
+
27
+ processed = {}
28
+
29
+ user_data.each do |key, value|
30
+ key_sym = key.to_sym
31
+
32
+ # Skip if not in allowed fields list (when specified)
33
+ if @allowed_fields && !@allowed_fields.include?(key_sym)
34
+ next
35
+ end
36
+
37
+ # Apply privacy filtering
38
+ if @privacy_mode && sensitive_field?(key_sym)
39
+ processed[key] = apply_privacy_filter(key_sym, value)
40
+ else
41
+ processed[key] = transform_field(key_sym, value)
42
+ end
43
+ end
44
+
45
+ processed
46
+ end
47
+
48
+ def merge_user_data(existing_data, new_data)
49
+ existing_processed = process_user_data(existing_data || {})
50
+ new_processed = process_user_data(new_data || {})
51
+
52
+ existing_processed.merge(new_processed)
53
+ end
54
+
55
+ def extract_user_id(user_data)
56
+ return nil unless user_data.is_a?(Hash)
57
+
58
+ # Try common user ID fields in order of preference
59
+ %i[id user_id uuid guid].each do |field|
60
+ value = user_data[field] || user_data[field.to_s]
61
+ return value if value
62
+ end
63
+
64
+ nil
65
+ end
66
+
67
+ def extract_user_segment(user_data)
68
+ return nil unless user_data.is_a?(Hash)
69
+
70
+ segments = {}
71
+
72
+ # Check for common user segments
73
+ segments[:internal] = !!(user_data[:internal] || user_data["internal"])
74
+ segments[:premium] = !!(user_data[:premium] || user_data["premium"])
75
+ segments[:beta] = !!(user_data[:beta] || user_data["beta"])
76
+ segments[:admin] = !!(user_data[:admin] || user_data["admin"])
77
+
78
+ # Check role-based segments
79
+ if role = (user_data[:role] || user_data["role"])
80
+ segments[:role] = role.to_s.downcase
81
+ end
82
+
83
+ # Check plan-based segments
84
+ if plan = (user_data[:plan] || user_data["plan"])
85
+ segments[:plan] = plan.to_s.downcase
86
+ end
87
+
88
+ segments.compact
89
+ end
90
+
91
+ def sanitize_for_logging(user_data)
92
+ return {} unless user_data.is_a?(Hash)
93
+
94
+ sanitized = {}
95
+
96
+ user_data.each do |key, value|
97
+ key_sym = key.to_sym
98
+
99
+ if sensitive_field?(key_sym)
100
+ sanitized[key] = "[REDACTED]"
101
+ elsif value.is_a?(Hash)
102
+ sanitized[key] = sanitize_for_logging(value)
103
+ elsif value.is_a?(Array)
104
+ sanitized[key] = value.map { |v| v.is_a?(Hash) ? sanitize_for_logging(v) : v }
105
+ else
106
+ sanitized[key] = value
107
+ end
108
+ end
109
+
110
+ sanitized
111
+ end
112
+
113
+ private
114
+
115
+ def sensitive_field?(field)
116
+ SENSITIVE_FIELDS.include?(field) || field.to_s.match?(/password|secret|token|key|ssn|credit|card/i)
117
+ end
118
+
119
+ def apply_privacy_filter(field, value)
120
+ case field
121
+ when :email
122
+ mask_email(value)
123
+ when :phone, :mobile, :telephone
124
+ mask_phone(value)
125
+ when :ip_address, :last_login_ip
126
+ mask_ip(value)
127
+ else
128
+ "[FILTERED]"
129
+ end
130
+ end
131
+
132
+ def transform_field(field, value)
133
+ if transformer = @field_transformers[field]
134
+ transformer.call(value)
135
+ else
136
+ value
137
+ end
138
+ end
139
+
140
+ def mask_email(email)
141
+ return "[INVALID_EMAIL]" unless email.is_a?(String) && email.include?("@")
142
+
143
+ local, domain = email.split("@", 2)
144
+ masked_local = local.length > 2 ? "#{local[0]}***#{local[-1]}" : "***"
145
+ "#{masked_local}@#{domain}"
146
+ end
147
+
148
+ def mask_phone(phone)
149
+ return "[INVALID_PHONE]" unless phone.is_a?(String)
150
+
151
+ # Remove all non-digits
152
+ digits = phone.gsub(/\D/, "")
153
+ return "[INVALID_PHONE]" if digits.length < 4
154
+
155
+ # Show last 4 digits
156
+ "*" * (digits.length - 4) + digits[-4..-1]
157
+ end
158
+
159
+ def mask_ip(ip)
160
+ return "[INVALID_IP]" unless ip.is_a?(String)
161
+
162
+ if ip.include?(":")
163
+ # IPv6 - mask last 4 groups
164
+ parts = ip.split(":")
165
+ parts[-4..-1] = ["****"] * 4 if parts.length >= 4
166
+ parts.join(":")
167
+ else
168
+ # IPv4 - mask last octet
169
+ parts = ip.split(".")
170
+ return "[INVALID_IP]" if parts.length != 4
171
+ parts[-1] = "***"
172
+ parts.join(".")
173
+ end
174
+ end
175
+ end
176
+
177
+ # User context provider that integrates with various authentication systems
178
+ class UserContextProvider
179
+ def initialize(providers: {})
180
+ @providers = providers
181
+ end
182
+
183
+ def get_user_context(event, hint = {})
184
+ context = {}
185
+
186
+ # Try each provider in order
187
+ @providers.each do |name, provider|
188
+ begin
189
+ if provider_context = provider.call(event, hint)
190
+ context.merge!(provider_context)
191
+ end
192
+ rescue StandardError => e
193
+ # Log provider error but don't fail
194
+ warn "User context provider #{name} failed: #{e.message}"
195
+ end
196
+ end
197
+
198
+ context
199
+ end
200
+
201
+ # Built-in providers for common authentication systems
202
+ def self.devise_provider
203
+ lambda do |event, hint|
204
+ return {} unless defined?(Devise) && defined?(Warden)
205
+
206
+ # Try to get user from Warden (used by Devise)
207
+ if request = hint[:request]
208
+ user = request.env["warden"]&.user
209
+ return {} unless user
210
+
211
+ {
212
+ id: user.id,
213
+ email: user.email,
214
+ username: user.respond_to?(:username) ? user.username : nil,
215
+ created_at: user.created_at,
216
+ role: user.respond_to?(:role) ? user.role : nil
217
+ }.compact
218
+ end
219
+
220
+ {}
221
+ end
222
+ end
223
+
224
+ def self.omniauth_provider
225
+ lambda do |event, hint|
226
+ return {} unless defined?(OmniAuth)
227
+
228
+ if request = hint[:request]
229
+ if auth_info = request.env["omniauth.auth"]
230
+ {
231
+ provider: auth_info["provider"],
232
+ uid: auth_info["uid"],
233
+ name: auth_info.dig("info", "name"),
234
+ email: auth_info.dig("info", "email"),
235
+ username: auth_info.dig("info", "nickname")
236
+ }.compact
237
+ end
238
+ end
239
+
240
+ {}
241
+ end
242
+ end
243
+
244
+ def self.session_provider
245
+ lambda do |event, hint|
246
+ return {} unless hint[:request]
247
+
248
+ request = hint[:request]
249
+ session = request.session rescue {}
250
+
251
+ {
252
+ session_id: session[:session_id] || session["session_id"],
253
+ user_id: session[:user_id] || session["user_id"],
254
+ csrf_token: session[:_csrf_token] || session["_csrf_token"]
255
+ }.compact
256
+ end
257
+ end
258
+
259
+ def self.thread_local_provider
260
+ lambda do |event, hint|
261
+ # Get user from thread-local storage
262
+ Thread.current[:current_user] || {}
263
+ end
264
+ end
265
+ end
266
+
267
+ # Integration with popular authentication gems
268
+ module UserContextIntegrations
269
+ def self.setup_devise_integration
270
+ return unless defined?(Devise)
271
+
272
+ # Add middleware to capture user context
273
+ if defined?(Rails)
274
+ Rails.application.config.middleware.use(UserContextMiddleware)
275
+ end
276
+ end
277
+
278
+ def self.setup_clearance_integration
279
+ return unless defined?(Clearance)
280
+
281
+ # Clearance integration
282
+ if defined?(Rails)
283
+ Rails.application.config.middleware.use(UserContextMiddleware) do |middleware|
284
+ middleware.user_provider = lambda do |request|
285
+ request.env[:clearance].current_user if request.env[:clearance]
286
+ end
287
+ end
288
+ end
289
+ end
290
+
291
+ def self.setup_authlogic_integration
292
+ return unless defined?(Authlogic)
293
+
294
+ # Authlogic integration
295
+ if defined?(Rails)
296
+ Rails.application.config.middleware.use(UserContextMiddleware) do |middleware|
297
+ middleware.user_provider = lambda do |request|
298
+ UserSession.find&.user if defined?(UserSession)
299
+ end
300
+ end
301
+ end
302
+ end
303
+ end
304
+
305
+ # Middleware to automatically capture user context
306
+ class UserContextMiddleware
307
+ def initialize(app, user_provider: nil)
308
+ @app = app
309
+ @user_provider = user_provider
310
+ end
311
+
312
+ def call(env)
313
+ request = Rack::Request.new(env)
314
+
315
+ # Capture user context
316
+ user_context = extract_user_context(request)
317
+
318
+ # Store in thread-local for access during request processing
319
+ Thread.current[:lapsoss_user_context] = user_context
320
+
321
+ @app.call(env)
322
+ ensure
323
+ Thread.current[:lapsoss_user_context] = nil
324
+ end
325
+
326
+ private
327
+
328
+ def extract_user_context(request)
329
+ if @user_provider
330
+ user = @user_provider.call(request)
331
+ return {} unless user
332
+
333
+ context = {
334
+ id: user.id,
335
+ email: user.email,
336
+ username: user.respond_to?(:username) ? user.username : nil
337
+ }
338
+
339
+ # Add role information if available
340
+ if user.respond_to?(:role)
341
+ context[:role] = user.role
342
+ end
343
+
344
+ # Add plan information if available
345
+ if user.respond_to?(:plan)
346
+ context[:plan] = user.plan
347
+ end
348
+
349
+ context.compact
350
+ else
351
+ {}
352
+ end
353
+ end
354
+ end
355
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Lapsoss
6
+ module Validators
7
+ class ValidationError < StandardError; end
8
+
9
+ module_function
10
+
11
+ def validate_presence!(value, name)
12
+ if value.nil? || (value.respond_to?(:empty?) && value.empty?)
13
+ raise ValidationError, "#{name} is required and cannot be nil or empty"
14
+ end
15
+ end
16
+
17
+ def validate_type!(value, types, name)
18
+ types = Array(types)
19
+ unless types.any? { |type| value.is_a?(type) }
20
+ type_names = types.map(&:name).join(' or ')
21
+ raise ValidationError, "#{name} must be a #{type_names}, got #{value.class} (#{value.inspect})"
22
+ end
23
+ end
24
+
25
+ def validate_callable!(value, name)
26
+ unless value.nil? || value.respond_to?(:call)
27
+ raise ValidationError, "#{name} must be callable (respond_to? :call) or nil, got #{value.class}"
28
+ end
29
+ end
30
+
31
+ def validate_numeric_range!(value, range, name)
32
+ validate_type!(value, [Numeric], name)
33
+ unless range.cover?(value)
34
+ raise ValidationError, "#{name} must be between #{range.min} and #{range.max}, got #{value}"
35
+ end
36
+ end
37
+
38
+ def validate_url!(value, name)
39
+ return if value.nil?
40
+
41
+ validate_type!(value, [String], name)
42
+
43
+ begin
44
+ uri = URI.parse(value)
45
+ unless uri.scheme && uri.host
46
+ raise ValidationError, "#{name} must be a valid URL with scheme and host"
47
+ end
48
+ unless %w[http https].include?(uri.scheme)
49
+ raise ValidationError, "#{name} must use http or https scheme"
50
+ end
51
+ rescue URI::InvalidURIError => e
52
+ raise ValidationError, "#{name} is not a valid URL: #{e.message}"
53
+ end
54
+ end
55
+
56
+ def validate_dsn!(dsn_string, name = "DSN")
57
+ return if dsn_string.nil?
58
+
59
+ validate_type!(dsn_string, [String], name)
60
+
61
+ begin
62
+ uri = URI.parse(dsn_string)
63
+
64
+ # Check required components
65
+ validate_presence!(uri.scheme, "#{name} scheme")
66
+ validate_presence!(uri.host, "#{name} host")
67
+ validate_presence!(uri.user, "#{name} public key")
68
+
69
+ # Validate scheme
70
+ unless %w[http https].include?(uri.scheme)
71
+ raise ValidationError, "#{name} must use http or https scheme"
72
+ end
73
+
74
+ # Extract project ID from path
75
+ path_parts = uri.path&.split("/") || []
76
+ project_id = path_parts.last
77
+ validate_presence!(project_id, "#{name} project ID")
78
+
79
+ # Validate project ID is numeric
80
+ unless project_id.match?(/^\d+$/)
81
+ raise ValidationError, "#{name} project ID must be numeric, got '#{project_id}'"
82
+ end
83
+
84
+ rescue URI::InvalidURIError => e
85
+ raise ValidationError, "#{name} is not a valid URI: #{e.message}"
86
+ end
87
+ end
88
+
89
+ def validate_api_key!(value, name, format: nil)
90
+ validate_presence!(value, name)
91
+ validate_type!(value, [String], name)
92
+
93
+ case format
94
+ when :uuid
95
+ unless value.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
96
+ raise ValidationError, "#{name} must be a valid UUID format"
97
+ end
98
+ when :hex
99
+ unless value.match?(/\A[0-9a-f]+\z/i)
100
+ raise ValidationError, "#{name} must be a valid hexadecimal string"
101
+ end
102
+ when :alphanumeric
103
+ unless value.match?(/\A[a-z0-9]+\z/i)
104
+ raise ValidationError, "#{name} must be alphanumeric"
105
+ end
106
+ end
107
+ end
108
+
109
+ def validate_environment!(value, name = "environment")
110
+ return if value.nil?
111
+
112
+ validate_type!(value, [String, Symbol], name)
113
+
114
+ env_string = value.to_s
115
+ unless env_string.match?(/\A[a-z0-9_-]+\z/i)
116
+ raise ValidationError, "#{name} must contain only alphanumeric characters, underscores, and hyphens"
117
+ end
118
+ end
119
+
120
+ def validate_sample_rate!(value, name = "sample_rate")
121
+ return if value.nil?
122
+
123
+ validate_numeric_range!(value, 0.0..1.0, name)
124
+ end
125
+
126
+ def validate_timeout!(value, name = "timeout")
127
+ return if value.nil?
128
+
129
+ validate_type!(value, [Numeric], name)
130
+ if value <= 0
131
+ raise ValidationError, "#{name} must be positive, got #{value}"
132
+ end
133
+ end
134
+
135
+ def validate_retries!(value, name = "max_retries")
136
+ return if value.nil?
137
+
138
+ validate_type!(value, [Integer], name)
139
+ validate_numeric_range!(value, 0..10, name)
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ VERSION = "0.1.0"
5
+ end
data/lib/lapsoss.rb ADDED
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lapsoss/version"
4
+ require_relative "lapsoss/configuration"
5
+ require_relative "lapsoss/registry"
6
+ require_relative "lapsoss/event"
7
+ require_relative "lapsoss/router"
8
+ require_relative "lapsoss/adapters/base"
9
+ require_relative "lapsoss/current"
10
+ require_relative "lapsoss/client"
11
+ require_relative "lapsoss/fingerprinter"
12
+ require_relative "lapsoss/backtrace_processor"
13
+ require_relative "lapsoss/scrubber"
14
+ require_relative "lapsoss/pipeline"
15
+ require_relative "lapsoss/sampling"
16
+ require_relative "lapsoss/user_context"
17
+ require_relative "lapsoss/exclusions"
18
+ require_relative "lapsoss/release_tracker"
19
+ require_relative "lapsoss/railtie" if defined?(Rails)
20
+
21
+ module Lapsoss
22
+ class Error < StandardError; end
23
+
24
+ class DeliveryError < Error
25
+ attr_reader :response, :cause
26
+
27
+ def initialize(message, response: nil, cause: nil)
28
+ super(message)
29
+ @response = response
30
+ @cause = cause
31
+ end
32
+ end
33
+
34
+ class << self
35
+ attr_reader :client
36
+
37
+ def configuration
38
+ @configuration ||= Configuration.new
39
+ end
40
+
41
+ def configure
42
+ yield(configuration)
43
+ configuration.validate!
44
+ configuration.apply!
45
+ @client = Client.new(configuration)
46
+ end
47
+
48
+ def capture_exception(exception, **context)
49
+ client.capture_exception(exception, **context)
50
+ end
51
+
52
+ def capture_message(message, level: :info, **context)
53
+ client.capture_message(message, level: level, **context)
54
+ end
55
+
56
+ def add_breadcrumb(message, type: :default, **metadata)
57
+ client.add_breadcrumb(message, type: type, **metadata)
58
+ end
59
+
60
+ def with_scope(context = {}, &block)
61
+ client.with_scope(context, &block)
62
+ end
63
+
64
+ def current_scope
65
+ client.current_scope
66
+ end
67
+
68
+ def flush(timeout: 2)
69
+ client.flush(timeout: timeout)
70
+ end
71
+
72
+ def shutdown
73
+ client.shutdown
74
+ end
75
+ end
76
+ end