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,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "json"
6
+ require "zlib"
7
+
8
+ module Lapsoss
9
+ # HTTP client wrapper using Faraday with retry logic
10
+ class HttpClient
11
+ USER_AGENT = "lapsoss/#{Lapsoss::VERSION}"
12
+
13
+ def initialize(base_url, config = {})
14
+ @base_url = base_url
15
+ @config = config
16
+ @connection = build_connection
17
+ end
18
+
19
+ def post(path, body:, headers: {})
20
+ response = @connection.post(path) do |req|
21
+ req.body = body
22
+ req.headers.merge!(headers)
23
+ end
24
+
25
+ unless response.success?
26
+ raise DeliveryError.new(
27
+ "HTTP #{response.status}: #{response.reason_phrase}",
28
+ response: response
29
+ )
30
+ end
31
+
32
+ response
33
+ rescue Faraday::Error => e
34
+ raise DeliveryError.new(
35
+ "Network error: #{e.message}",
36
+ cause: e
37
+ )
38
+ end
39
+
40
+ def shutdown
41
+ # Faraday connections don't need explicit shutdown
42
+ end
43
+
44
+ private
45
+
46
+ def build_connection
47
+ Faraday.new(@base_url) do |conn|
48
+ # Configure retry middleware
49
+ conn.request :retry, retry_options
50
+
51
+ # Configure timeouts
52
+ conn.options.timeout = @config[:timeout] || 5
53
+ conn.options.open_timeout = @config[:timeout] || 5
54
+
55
+ # Configure SSL
56
+ if @config.key?(:ssl_verify)
57
+ conn.ssl.verify = @config[:ssl_verify]
58
+ end
59
+
60
+ # Set user agent
61
+ conn.headers["User-Agent"] = USER_AGENT
62
+
63
+ # Use default adapter
64
+ conn.adapter Faraday.default_adapter
65
+ end
66
+ end
67
+
68
+ def retry_options
69
+ {
70
+ max: @config[:max_retries] || 3,
71
+ interval: @config[:initial_backoff] || 1.0,
72
+ max_interval: @config[:max_backoff] || 64.0,
73
+ backoff_factor: @config[:backoff_multiplier] || 2.0,
74
+ retry_statuses: [408, 429, 500, 502, 503, 504],
75
+ methods: [:post]
76
+ }
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Middleware
5
+ class Base
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(event, hint = {})
11
+ @app.call(event, hint)
12
+ end
13
+ end
14
+
15
+ # Built-in middleware for common functionality
16
+ class SampleFilter < Base
17
+ def initialize(app, sample_rate: 1.0, sample_callback: nil)
18
+ super(app)
19
+ @sample_rate = sample_rate
20
+ @sample_callback = sample_callback
21
+ end
22
+
23
+ def call(event, hint = {})
24
+ # Apply custom sampling logic first
25
+ if @sample_callback
26
+ return nil unless @sample_callback.call(event, hint)
27
+ end
28
+
29
+ # Apply rate-based sampling
30
+ if @sample_rate < 1.0
31
+ return nil if rand > @sample_rate
32
+ end
33
+
34
+ @app.call(event, hint)
35
+ end
36
+ end
37
+
38
+ class ExceptionFilter < Base
39
+ def initialize(app, excluded_exceptions: [], excluded_patterns: [])
40
+ super(app)
41
+ @excluded_exceptions = Array(excluded_exceptions)
42
+ @excluded_patterns = Array(excluded_patterns)
43
+ end
44
+
45
+ def call(event, hint = {})
46
+ return nil if should_exclude?(event)
47
+ @app.call(event, hint)
48
+ end
49
+
50
+ private
51
+
52
+ def should_exclude?(event)
53
+ return false unless event.exception
54
+
55
+ exception_class = event.exception.class
56
+ exception_message = event.exception.message
57
+
58
+ # Check exact class matches
59
+ return true if @excluded_exceptions.any? { |klass| exception_class <= klass }
60
+
61
+ # Check pattern matches
62
+ @excluded_patterns.any? do |pattern|
63
+ case pattern
64
+ when Regexp
65
+ exception_message&.match?(pattern) || exception_class.name.match?(pattern)
66
+ when String
67
+ exception_message&.include?(pattern) || exception_class.name.include?(pattern)
68
+ else
69
+ false
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ class UserContextEnhancer < Base
76
+ def initialize(app, user_provider: nil, privacy_mode: false)
77
+ super(app)
78
+ @user_provider = user_provider
79
+ @privacy_mode = privacy_mode
80
+ end
81
+
82
+ def call(event, hint = {})
83
+ enhance_user_context(event, hint)
84
+ @app.call(event, hint)
85
+ end
86
+
87
+ private
88
+
89
+ def enhance_user_context(event, hint)
90
+ # Get user from provider if available
91
+ user_data = @user_provider&.call(event, hint) || {}
92
+
93
+ # Merge with existing user context
94
+ existing_user = event.context[:user] || {}
95
+ enhanced_user = existing_user.merge(user_data)
96
+
97
+ # Apply privacy filtering if enabled
98
+ if @privacy_mode
99
+ enhanced_user = apply_privacy_filtering(enhanced_user)
100
+ end
101
+
102
+ event.context[:user] = enhanced_user unless enhanced_user.empty?
103
+ end
104
+
105
+ def apply_privacy_filtering(user_data)
106
+ # Remove sensitive fields in privacy mode
107
+ sensitive_fields = [:email, :phone, :address, :ssn, :credit_card]
108
+ filtered = user_data.dup
109
+
110
+ sensitive_fields.each do |field|
111
+ if filtered.key?(field)
112
+ filtered[field] = "[FILTERED]"
113
+ end
114
+ end
115
+
116
+ filtered
117
+ end
118
+ end
119
+
120
+ class ReleaseTracker < Base
121
+ def initialize(app, release_provider: nil)
122
+ super(app)
123
+ @release_provider = release_provider
124
+ end
125
+
126
+ def call(event, hint = {})
127
+ add_release_info(event, hint)
128
+ @app.call(event, hint)
129
+ end
130
+
131
+ private
132
+
133
+ def add_release_info(event, hint)
134
+ release_info = @release_provider&.call(event, hint) || auto_detect_release
135
+
136
+ if release_info
137
+ event.context[:release] = release_info
138
+ end
139
+ end
140
+
141
+ def auto_detect_release
142
+ release_info = {}
143
+
144
+ # Try to detect Git information
145
+ if git_info = detect_git_info
146
+ release_info.merge!(git_info)
147
+ end
148
+
149
+ # Try to detect deployment info
150
+ if deployment_info = detect_deployment_info
151
+ release_info.merge!(deployment_info)
152
+ end
153
+
154
+ release_info.empty? ? nil : release_info
155
+ end
156
+
157
+ def detect_git_info
158
+ return nil unless File.exist?(".git")
159
+
160
+ begin
161
+ # Get current commit SHA
162
+ commit_sha = `git rev-parse HEAD`.strip
163
+ return nil if commit_sha.empty?
164
+
165
+ # Get branch name
166
+ branch = `git rev-parse --abbrev-ref HEAD`.strip
167
+ branch = nil if branch.empty? || branch == "HEAD"
168
+
169
+ # Get commit timestamp
170
+ commit_time = `git log -1 --format=%ct`.strip
171
+ commit_timestamp = commit_time.empty? ? nil : Time.at(commit_time.to_i)
172
+
173
+ # Get tag if on a tag
174
+ tag = `git describe --exact-match --tags HEAD 2>/dev/null`.strip
175
+ tag = nil if tag.empty?
176
+
177
+ {
178
+ commit_sha: commit_sha,
179
+ branch: branch,
180
+ tag: tag,
181
+ commit_timestamp: commit_timestamp
182
+ }.compact
183
+ rescue StandardError
184
+ nil
185
+ end
186
+ end
187
+
188
+ def detect_deployment_info
189
+ info = {}
190
+
191
+ # Check common deployment environment variables
192
+ info[:deployment_id] = ENV["DEPLOYMENT_ID"] if ENV["DEPLOYMENT_ID"]
193
+ info[:build_number] = ENV["BUILD_NUMBER"] if ENV["BUILD_NUMBER"]
194
+ info[:deployment_time] = parse_deployment_time(ENV["DEPLOYMENT_TIME"]) if ENV["DEPLOYMENT_TIME"]
195
+
196
+ # Check Heroku
197
+ if ENV["HEROKU_APP_NAME"]
198
+ info[:platform] = "heroku"
199
+ info[:app_name] = ENV["HEROKU_APP_NAME"]
200
+ info[:dyno] = ENV["DYNO"]
201
+ info[:slug_commit] = ENV["HEROKU_SLUG_COMMIT"]
202
+ end
203
+
204
+ # Check AWS
205
+ if ENV["AWS_EXECUTION_ENV"]
206
+ info[:platform] = "aws"
207
+ info[:execution_env] = ENV["AWS_EXECUTION_ENV"]
208
+ info[:region] = ENV["AWS_REGION"]
209
+ end
210
+
211
+ # Check Docker
212
+ if ENV["DOCKER_CONTAINER_ID"] || File.exist?("/.dockerenv")
213
+ info[:platform] = "docker"
214
+ info[:container_id] = ENV["DOCKER_CONTAINER_ID"]
215
+ end
216
+
217
+ # Check Kubernetes
218
+ if ENV["KUBERNETES_SERVICE_HOST"]
219
+ info[:platform] = "kubernetes"
220
+ info[:namespace] = ENV["KUBERNETES_NAMESPACE"]
221
+ info[:pod_name] = ENV["HOSTNAME"]
222
+ end
223
+
224
+ info.empty? ? nil : info
225
+ end
226
+
227
+ def parse_deployment_time(time_str)
228
+ Time.parse(time_str)
229
+ rescue StandardError
230
+ nil
231
+ end
232
+ end
233
+
234
+ class EventEnricher < Base
235
+ def initialize(app, enrichers: [])
236
+ super(app)
237
+ @enrichers = enrichers
238
+ end
239
+
240
+ def call(event, hint = {})
241
+ @enrichers.each do |enricher|
242
+ enricher.call(event, hint)
243
+ end
244
+ @app.call(event, hint)
245
+ end
246
+ end
247
+
248
+ class ConditionalFilter < Base
249
+ def initialize(app, condition)
250
+ super(app)
251
+ @condition = condition
252
+ end
253
+
254
+ def call(event, hint = {})
255
+ return nil unless @condition.call(event, hint)
256
+ @app.call(event, hint)
257
+ end
258
+ end
259
+
260
+ class EventTransformer < Base
261
+ def initialize(app, transformer)
262
+ super(app)
263
+ @transformer = transformer
264
+ end
265
+
266
+ def call(event, hint = {})
267
+ transformed_event = @transformer.call(event, hint)
268
+ return nil unless transformed_event
269
+ @app.call(transformed_event, hint)
270
+ end
271
+ end
272
+
273
+ class RateLimiter < Base
274
+ def initialize(app, max_events: 100, time_window: 60)
275
+ super(app)
276
+ @max_events = max_events
277
+ @time_window = time_window
278
+ @events = []
279
+ @mutex = Mutex.new
280
+ end
281
+
282
+ def call(event, hint = {})
283
+ @mutex.synchronize do
284
+ now = Time.now
285
+ # Remove old events outside time window
286
+ @events.reject! { |timestamp| now - timestamp > @time_window }
287
+
288
+ # Check if we're over the limit
289
+ if @events.length >= @max_events
290
+ return nil
291
+ end
292
+
293
+ # Add current event
294
+ @events << now
295
+ end
296
+
297
+ @app.call(event, hint)
298
+ end
299
+ end
300
+
301
+ class MetricsCollector < Base
302
+ def initialize(app, collector: nil)
303
+ super(app)
304
+ @collector = collector
305
+ @metrics = {
306
+ events_processed: 0,
307
+ events_dropped: 0,
308
+ events_by_type: Hash.new(0),
309
+ events_by_level: Hash.new(0)
310
+ }
311
+ @mutex = Mutex.new
312
+ end
313
+
314
+ def call(event, hint = {})
315
+ @mutex.synchronize do
316
+ @metrics[:events_processed] += 1
317
+ @metrics[:events_by_type][event.type] += 1
318
+ @metrics[:events_by_level][event.level] += 1
319
+ end
320
+
321
+ result = @app.call(event, hint)
322
+
323
+ if result.nil?
324
+ @mutex.synchronize do
325
+ @metrics[:events_dropped] += 1
326
+ end
327
+ end
328
+
329
+ # Send to external collector if provided
330
+ @collector&.call(@metrics.dup, event, hint)
331
+
332
+ result
333
+ end
334
+
335
+ def metrics
336
+ @mutex.synchronize { @metrics.dup }
337
+ end
338
+ end
339
+
340
+ # Middleware wrapper for sampling strategies
341
+ class SamplingMiddleware < Base
342
+ def initialize(app, sampler)
343
+ super(app)
344
+ @sampler = sampler
345
+ end
346
+
347
+ def call(event, hint = {})
348
+ return nil unless @sampler.sample?(event, hint)
349
+ @app.call(event, hint)
350
+ end
351
+ end
352
+ end
353
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "middleware"
4
+
5
+ module Lapsoss
6
+ class Pipeline
7
+ def initialize
8
+ @middlewares = []
9
+ @built = false
10
+ @app = nil
11
+ end
12
+
13
+ def use(middleware_class, *args, **kwargs)
14
+ raise "Cannot modify pipeline after it's built" if @built
15
+
16
+ @middlewares << { class: middleware_class, args: args, kwargs: kwargs }
17
+ self
18
+ end
19
+
20
+ def build
21
+ return @app if @built
22
+
23
+ # Build the middleware chain from inside out
24
+ @app = build_chain
25
+ @built = true
26
+ @app
27
+ end
28
+
29
+ def call(event, hint = {})
30
+ build unless @built
31
+ @app.call(event, hint)
32
+ end
33
+
34
+ def reset
35
+ @middlewares.clear
36
+ @built = false
37
+ @app = nil
38
+ self
39
+ end
40
+
41
+ def middlewares
42
+ @middlewares.dup
43
+ end
44
+
45
+ private
46
+
47
+ def build_chain
48
+ # The final app just returns the event
49
+ final_app = lambda { |event, hint| event }
50
+
51
+ # Build middleware chain from right to left
52
+ @middlewares.reverse.reduce(final_app) do |app, middleware_config|
53
+ klass = middleware_config[:class]
54
+ args = middleware_config[:args]
55
+ kwargs = middleware_config[:kwargs]
56
+
57
+ klass.new(app, *args, **kwargs)
58
+ end
59
+ end
60
+ end
61
+
62
+ class PipelineBuilder
63
+ def initialize
64
+ @pipeline = Pipeline.new
65
+ end
66
+
67
+ def sample(rate: 1.0, &block)
68
+ @pipeline.use(Middleware::SampleFilter, sample_rate: rate, sample_callback: block)
69
+ self
70
+ end
71
+
72
+ def exclude_exceptions(*exception_classes, patterns: [])
73
+ @pipeline.use(Middleware::ExceptionFilter,
74
+ excluded_exceptions: exception_classes,
75
+ excluded_patterns: patterns)
76
+ self
77
+ end
78
+
79
+ def enhance_user_context(provider: nil, privacy_mode: false)
80
+ @pipeline.use(Middleware::UserContextEnhancer,
81
+ user_provider: provider,
82
+ privacy_mode: privacy_mode)
83
+ self
84
+ end
85
+
86
+ def track_releases(provider: nil)
87
+ @pipeline.use(Middleware::ReleaseTracker, release_provider: provider)
88
+ self
89
+ end
90
+
91
+ def rate_limit(max_events: 100, time_window: 60)
92
+ @pipeline.use(Middleware::RateLimiter,
93
+ max_events: max_events,
94
+ time_window: time_window)
95
+ self
96
+ end
97
+
98
+ def collect_metrics(collector: nil)
99
+ @pipeline.use(Middleware::MetricsCollector, collector: collector)
100
+ self
101
+ end
102
+
103
+ def enrich_events(*enrichers)
104
+ @pipeline.use(Middleware::EventEnricher, enrichers: enrichers)
105
+ self
106
+ end
107
+
108
+ def filter_if(&condition)
109
+ @pipeline.use(Middleware::ConditionalFilter, condition)
110
+ self
111
+ end
112
+
113
+ def transform_events(&transformer)
114
+ @pipeline.use(Middleware::EventTransformer, transformer)
115
+ self
116
+ end
117
+
118
+ def use_middleware(middleware_class, *args, **kwargs)
119
+ @pipeline.use(middleware_class, *args, **kwargs)
120
+ self
121
+ end
122
+
123
+ def build
124
+ @pipeline.build
125
+ end
126
+
127
+ def pipeline
128
+ @pipeline
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ class Railtie < Rails::Railtie
5
+ config.lapsoss = ActiveSupport::OrderedOptions.new
6
+
7
+ initializer "lapsoss.configure" do |app|
8
+ Lapsoss.configure do |config|
9
+ config.environment ||= Rails.env
10
+ config.logger ||= Rails.logger
11
+ config.release ||= Rails.application.config.try(:release)
12
+
13
+ # Set default tags
14
+ config.default_tags = {
15
+ rails_env: Rails.env,
16
+ rails_version: Rails.version
17
+ }
18
+ end
19
+ end
20
+
21
+ initializer "lapsoss.rails_error_subscriber", after: "lapsoss.configure" do |app|
22
+ if Rails.version.to_f >= 7.0
23
+ app.executor.error_reporter.subscribe(Lapsoss::RailsErrorSubscriber.new)
24
+ end
25
+ end
26
+
27
+
28
+
29
+ rake_tasks do
30
+ # Add any Lapsoss-specific rake tasks here
31
+ end
32
+ end
33
+
34
+ class RailsErrorSubscriber
35
+ def report(error, handled:, severity:, context:, source: nil)
36
+ # Skip certain framework errors
37
+ return if skip_error?(error, source)
38
+
39
+ level = map_severity(severity)
40
+
41
+ Lapsoss.capture_exception(
42
+ error,
43
+ level: level,
44
+ tags: {
45
+ handled: handled,
46
+ source: source || "rails"
47
+ },
48
+ context: context
49
+ )
50
+ end
51
+
52
+ private
53
+
54
+ def skip_error?(error, source)
55
+ # Skip cache-related Redis errors if configured to do so
56
+ if Lapsoss.configuration.skip_rails_cache_errors
57
+ return true if source&.include?("cache") && error.is_a?(Redis::CannotConnectError)
58
+ end
59
+
60
+ false
61
+ end
62
+
63
+ def map_severity(severity)
64
+ case severity
65
+ when :error then :error
66
+ when :warning then :warning
67
+ when :info then :info
68
+ else :error
69
+ end
70
+ end
71
+ end
72
+ end