lapsoss 0.4.10 → 1.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76c4609d94f1d9463922da6d1850b7b618977075dd0d96fa7d94d58186cd64b9
4
- data.tar.gz: 8d5db54e63ef1e6a0d73962261bd13ce4cfca305c63570bf879c4dbf85bb3110
3
+ metadata.gz: 7c8f5f1fbdb514808ad6accd57f448a1d997f86f8c53224456421879e78ee40e
4
+ data.tar.gz: ee76c128c4b6f7b0f7d51ff81700258d6a8e1fbde522400009fa682b49658c7f
5
5
  SHA512:
6
- metadata.gz: ac8bc565e2d54b1c3f600de8b2a6fedc0e2da481ce3c9003c587b81bd4134209618ba77dd3ae1019188537217c09c2f8317640ea36d46b91a229116eeaafd032
7
- data.tar.gz: eee117f27719db7cfe019466cfbe5ec1210e86c0cfb0a7bff6764984acb148fb2233dd0c84fb7029e0e643e38f2147989f41172035a41dbd79a35eecad79763f
6
+ metadata.gz: '0591e240fabe7f68e179a09f55d61bc03f26374ca7abd28bc4f5a67789644670342ef43d7f56ebff6c3e409e7df18fd77ae11e82ae5d6fe44af582add4e20efb'
7
+ data.tar.gz: f85e9148f94abe28ff69693169fc600641c99cf1d2f0d0204b95c3c3582e039d5fab19cc9a7b8442e09dd72b2d2c2ce33f71339d0967b068a3c1363dab583bd5
data/README.md CHANGED
@@ -39,7 +39,7 @@ end
39
39
  ## Requirements
40
40
 
41
41
  - Ruby 3.3+
42
- - Rails 7.2+
42
+ - Rails 8.1+
43
43
 
44
44
  ## Installation
45
45
 
@@ -69,7 +69,7 @@ That's it. No 500-line examples needed.
69
69
 
70
70
  ## Built for Rails, Not Around It
71
71
 
72
- Lapsoss integrates with Rails' native error reporting API introduced in Rails 7. No monkey-patching, no global error handlers:
72
+ Lapsoss integrates with Rails' native error reporting API (`Rails.error`). No monkey-patching, no global error handlers:
73
73
 
74
74
  ```ruby
75
75
  # It just works with Rails.error:
@@ -116,6 +116,51 @@ end
116
116
 
117
117
  # Or use Rails.error directly with your configured services
118
118
  Rails.error.report(e, context: { user_id: current_user.id })
119
+
120
+ # For "should never happen" paths, Rails.error.unexpected raises in
121
+ # development/test and reports in production - and routes through Lapsoss too
122
+ Rails.error.unexpected("Reached unreachable branch")
123
+ ```
124
+
125
+ ### Structured Events as Breadcrumbs
126
+
127
+ Lapsoss automatically subscribes to the structured event reporter
128
+ (`Rails.event`) and records emitted events as breadcrumbs. When an error is
129
+ captured, the recent activity trail is attached to the report - no
130
+ monkey-patching, just Rails' native API:
131
+
132
+ ```ruby
133
+ Rails.event.notify("order.checkout_started", cart_id: cart.id)
134
+ # ... an exception here includes that breadcrumb in the error report
135
+
136
+ # Tags and context flow into breadcrumb metadata
137
+ Rails.event.tagged(section: "checkout") do
138
+ Rails.event.notify("payment.authorized", amount: 42_00)
139
+ end
140
+ ```
141
+
142
+ Tune or disable it:
143
+
144
+ ```ruby
145
+ Lapsoss.configure do |config|
146
+ config.capture_rails_events = false # opt out entirely
147
+ config.rails_event_filter = ->(event) { !event[:name].start_with?("noisy.") }
148
+ end
149
+ ```
150
+
151
+ Rails-side enrichment also composes: context added via `Rails.error.add_middleware`
152
+ flows into the `context:` Lapsoss receives for free.
153
+
154
+ ### Silencing Capture
155
+
156
+ Suppress delivery for a block (thread-local) - useful in tests or around
157
+ expected-failure paths. Breadcrumbs and scope still accumulate; only delivery
158
+ is skipped:
159
+
160
+ ```ruby
161
+ Lapsoss.silence do
162
+ retry_flaky_third_party_call
163
+ end
119
164
  ```
120
165
 
121
166
  ### No Global Patching Philosophy
@@ -220,6 +265,8 @@ All adapters are pure Ruby implementations with no external SDK dependencies:
220
265
  - **AppSignal** - Error tracking and deploy markers
221
266
  - **Insight Hub** (formerly Bugsnag) - Error tracking with breadcrumbs
222
267
  - **Telebugs** - Sentry-compatible protocol (perfect for self-hosted alternatives)
268
+ - **OpenObserve** - Open-source observability platform (logs, metrics, traces)
269
+ - **OTLP** - OpenTelemetry Protocol (works with SigNoz, Jaeger, Tempo, Honeycomb, etc.)
223
270
 
224
271
  ## Configuration
225
272
 
@@ -254,6 +301,39 @@ Lapsoss.configure do |config|
254
301
  end
255
302
  ```
256
303
 
304
+ ### Using OpenObserve
305
+
306
+ ```ruby
307
+ # OpenObserve - open-source observability platform
308
+ Lapsoss.configure do |config|
309
+ config.use_openobserve(
310
+ endpoint: ENV['OPENOBSERVE_ENDPOINT'], # e.g., "http://localhost:5080"
311
+ username: ENV['OPENOBSERVE_USERNAME'],
312
+ password: ENV['OPENOBSERVE_PASSWORD'],
313
+ org: "default", # optional, defaults to "default"
314
+ stream: "errors" # optional, defaults to "errors"
315
+ )
316
+ end
317
+ ```
318
+
319
+ ### Using OTLP (OpenTelemetry Protocol)
320
+
321
+ ```ruby
322
+ # Works with SigNoz, Jaeger, Tempo, Honeycomb, Datadog, etc.
323
+ Lapsoss.configure do |config|
324
+ config.use_otlp(
325
+ endpoint: ENV['OTLP_ENDPOINT'], # e.g., "http://localhost:4318"
326
+ service_name: "my-rails-app",
327
+ headers: { "X-Custom-Header" => "value" } # optional custom headers
328
+ )
329
+
330
+ # Or use convenience helpers for specific services
331
+ config.use_signoz(signoz_api_key: ENV['SIGNOZ_API_KEY'])
332
+ config.use_jaeger(endpoint: "http://jaeger:4318")
333
+ config.use_tempo(endpoint: "http://tempo:4318")
334
+ end
335
+ ```
336
+
257
337
  ### Advanced Configuration
258
338
 
259
339
  ```ruby
@@ -276,9 +356,27 @@ Lapsoss.configure do |config|
276
356
  end
277
357
  ```
278
358
 
359
+ ### Pipeline & Sampling (optional)
360
+
361
+ ```ruby
362
+ Lapsoss.configure do |config|
363
+ # Build a middleware pipeline for every event
364
+ config.configure_pipeline do |pipeline|
365
+ pipeline.sample(rate: 0.1) # Drop 90% of events
366
+
367
+ pipeline.enhance_user_context(
368
+ provider: ->(event, _) { current_user&.slice(:id, :email) },
369
+ privacy_mode: true # keep only ids
370
+ )
371
+ end
372
+ end
373
+ ```
374
+
279
375
  ### Filtering Errors
280
376
 
281
- You decide what errors to track. Lapsoss doesn't make assumptions:
377
+ You decide what errors to track. Lapsoss doesn't make assumptions.
378
+
379
+ **Execution order:** `exclusion_filter` runs first, then `before_send`.
282
380
 
283
381
  ```ruby
284
382
  Lapsoss.configure do |config|
@@ -351,6 +449,30 @@ Rails.application.config.filter_parameters += [:password, :token]
351
449
  # Lapsoss automatically uses these filters - no additional configuration needed!
352
450
  ```
353
451
 
452
+ Additional controls:
453
+
454
+ ```ruby
455
+ Lapsoss.configure do |config|
456
+ config.scrub_fields = %w[credit_card ssn api_key]
457
+ config.scrub_all = true # Mask everything by default
458
+ config.whitelist_fields = %w[user_id request_id] # Keep these fields as-is
459
+ config.randomize_scrub_length = true # Avoid fixed "[FILTERED]" marker
460
+ end
461
+ ```
462
+
463
+ ### Error Handler Hook
464
+
465
+ Get a callback when an adapter fails to deliver:
466
+
467
+ ```ruby
468
+ Lapsoss.configure do |config|
469
+ config.error_handler = lambda do |adapter, event, error|
470
+ Rails.logger.error("Delivery failed for #{adapter.name}: #{error.message}")
471
+ Rails.logger.error(event.to_h) if Rails.env.development?
472
+ end
473
+ end
474
+ ```
475
+
354
476
  ### Custom Fingerprinting
355
477
 
356
478
  Control how errors are grouped:
@@ -12,8 +12,8 @@ module Lapsoss
12
12
  extend ActiveSupport::Concern
13
13
 
14
14
  included do
15
- class_attribute :api_endpoint, instance_writer: false
16
- class_attribute :api_path, default: "/", instance_writer: false
15
+ # Instance-level endpoint configuration for adapter isolation
16
+ attr_reader :api_endpoint, :api_path
17
17
 
18
18
  # Memoized git info using AS
19
19
  mattr_accessor :git_info_cache, default: {}
@@ -39,7 +39,7 @@ module Lapsoss
39
39
  handle_response(response)
40
40
  end
41
41
  rescue => error
42
- handle_delivery_error(error)
42
+ handle_delivery_error(error, event)
43
43
  end
44
44
 
45
45
  # Common headers for all adapters
@@ -101,7 +101,7 @@ module Lapsoss
101
101
  raise DeliveryError.new("Client error: #{message}", response: response)
102
102
  end
103
103
 
104
- def handle_delivery_error(error)
104
+ def handle_delivery_error(error, event = nil)
105
105
  ActiveSupport::Notifications.instrument("error.lapsoss",
106
106
  adapter: self.class.name,
107
107
  error: error.class.name,
@@ -109,10 +109,22 @@ module Lapsoss
109
109
  )
110
110
 
111
111
  Lapsoss.configuration.logger&.error("[#{self.class.name}] Delivery failed: #{error.message}")
112
- Lapsoss.configuration.error_handler&.call(error)
112
+ Lapsoss.call_error_handler(adapter: self, event: event, error: error)
113
+ mark_error_handled(error)
113
114
 
114
- raise error if error.is_a?(DeliveryError)
115
- raise DeliveryError.new("Delivery failed: #{error.message}", cause: error)
115
+ if error.is_a?(DeliveryError)
116
+ raise error
117
+ else
118
+ delivery_error = DeliveryError.new("Delivery failed: #{error.message}", cause: error)
119
+ mark_error_handled(delivery_error)
120
+ raise delivery_error
121
+ end
122
+ end
123
+
124
+ def mark_error_handled(error)
125
+ error.instance_variable_set(:@lapsoss_error_handled, true)
126
+ rescue StandardError
127
+ # If setting the flag fails, we still continue
116
128
  end
117
129
 
118
130
  private
@@ -44,6 +44,16 @@ module Lapsoss
44
44
  error: "error",
45
45
  fatal: "error",
46
46
  critical: "error"
47
+ }.with_indifferent_access,
48
+
49
+ openobserve: {
50
+ debug: "DEBUG",
51
+ info: "INFO",
52
+ warning: "WARN",
53
+ warn: "WARN",
54
+ error: "ERROR",
55
+ fatal: "FATAL",
56
+ critical: "FATAL"
47
57
  }.with_indifferent_access
48
58
  }.freeze
49
59
 
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/object/blank"
5
+
6
+ module Lapsoss
7
+ module Adapters
8
+ module Concerns
9
+ # Shared stacktrace building logic for adapters
10
+ # Provides consistent frame formatting across Sentry, OpenObserve, OTLP, etc.
11
+ module StacktraceBuilder
12
+ extend ActiveSupport::Concern
13
+
14
+ # Build frames from event backtrace
15
+ # @param event [Lapsoss::Event] The event with backtrace_frames
16
+ # @param reverse [Boolean] Reverse frame order (Sentry expects oldest-to-newest)
17
+ # @return [Array<Hash>] Array of formatted frame hashes
18
+ def build_frames(event, reverse: false)
19
+ return [] unless event.has_backtrace?
20
+
21
+ frames = event.backtrace_frames.map { |frame| build_frame(frame) }
22
+ reverse ? frames.reverse : frames
23
+ end
24
+
25
+ # Build a single frame hash from a BacktraceFrame
26
+ # @param frame [Lapsoss::BacktraceFrame] The frame to format
27
+ # @return [Hash] Formatted frame hash
28
+ def build_frame(frame)
29
+ frame_hash = {
30
+ filename: frame.filename,
31
+ abs_path: frame.absolute_path || frame.filename,
32
+ function: frame.method_name || frame.function,
33
+ lineno: frame.line_number,
34
+ in_app: frame.in_app
35
+ }
36
+
37
+ add_code_context(frame_hash, frame) if frame.code_context.present?
38
+
39
+ frame_hash.compact
40
+ end
41
+
42
+ # Build raw stacktrace string for logging/simple formats
43
+ # @param event [Lapsoss::Event] The event with backtrace_frames
44
+ # @return [Array<String>] Array of formatted frame strings
45
+ def build_raw_stacktrace(event)
46
+ return [] unless event.has_backtrace?
47
+
48
+ event.backtrace_frames.map do |frame|
49
+ "#{frame.absolute_path || frame.filename}:#{frame.line_number} in `#{frame.method_name}`"
50
+ end
51
+ end
52
+
53
+ # Build stacktrace as single string (for OTLP exception.stacktrace)
54
+ # @param event [Lapsoss::Event] The event with backtrace_frames
55
+ # @return [String] Newline-separated stacktrace
56
+ def build_stacktrace_string(event)
57
+ build_raw_stacktrace(event).join("\n")
58
+ end
59
+
60
+ private
61
+
62
+ def add_code_context(frame_hash, frame)
63
+ frame_hash[:pre_context] = frame.code_context[:pre_context]
64
+ frame_hash[:context_line] = frame.code_context[:context_line]
65
+ frame_hash[:post_context] = frame.code_context[:post_context]
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "securerandom"
5
+
6
+ module Lapsoss
7
+ module Adapters
8
+ module Concerns
9
+ # Trace context utilities for OTLP and distributed tracing
10
+ # Provides trace/span ID generation and timestamp formatting
11
+ module TraceContext
12
+ extend ActiveSupport::Concern
13
+
14
+ # Generate a W3C Trace Context compliant trace ID (32 hex chars = 128 bits)
15
+ # @return [String] 32 character hex string
16
+ def generate_trace_id
17
+ SecureRandom.hex(16)
18
+ end
19
+
20
+ # Generate a W3C Trace Context compliant span ID (16 hex chars = 64 bits)
21
+ # @return [String] 16 character hex string
22
+ def generate_span_id
23
+ SecureRandom.hex(8)
24
+ end
25
+
26
+ # Convert time to nanoseconds since Unix epoch (OTLP format)
27
+ # @param time [Time] The time to convert
28
+ # @return [Integer] Nanoseconds since Unix epoch
29
+ def timestamp_nanos(time)
30
+ time ||= Time.current
31
+ (time.to_f * 1_000_000_000).to_i
32
+ end
33
+
34
+ # Convert time to microseconds since Unix epoch (OpenObserve format)
35
+ # @param time [Time] The time to convert
36
+ # @return [Integer] Microseconds since Unix epoch
37
+ def timestamp_micros(time)
38
+ time ||= Time.current
39
+ (time.to_f * 1_000_000).to_i
40
+ end
41
+
42
+ # Convert time to milliseconds since Unix epoch
43
+ # @param time [Time] The time to convert
44
+ # @return [Integer] Milliseconds since Unix epoch
45
+ def timestamp_millis(time)
46
+ time ||= Time.current
47
+ (time.to_f * 1_000).to_i
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+ require "base64"
5
+
6
+ module Lapsoss
7
+ module Adapters
8
+ # OpenObserve adapter - sends errors as structured JSON logs
9
+ # OpenObserve is an observability platform that accepts logs via simple JSON API
10
+ # Docs: https://openobserve.ai/docs/ingestion/
11
+ class OpenobserveAdapter < Base
12
+ include Concerns::LevelMapping
13
+ include Concerns::HttpDelivery
14
+
15
+ self.level_mapping_type = :openobserve
16
+
17
+ DEFAULT_STREAM = "errors"
18
+ DEFAULT_ORG = "default"
19
+
20
+ def initialize(name, settings = {})
21
+ super
22
+
23
+ @endpoint = settings[:endpoint].presence || ENV["OPENOBSERVE_ENDPOINT"]
24
+ @username = settings[:username].presence || ENV["OPENOBSERVE_USERNAME"]
25
+ @password = settings[:password].presence || ENV["OPENOBSERVE_PASSWORD"]
26
+ @org = settings[:org].presence || ENV["OPENOBSERVE_ORG"] || DEFAULT_ORG
27
+ @stream = settings[:stream].presence || ENV["OPENOBSERVE_STREAM"] || DEFAULT_STREAM
28
+
29
+ if @endpoint.blank? || @username.blank? || @password.blank?
30
+ Lapsoss.configuration.logger&.warn "[Lapsoss::OpenobserveAdapter] Missing endpoint, username or password - adapter disabled"
31
+ @enabled = false
32
+ return
33
+ end
34
+
35
+ setup_endpoint
36
+ end
37
+
38
+ def capture(event)
39
+ deliver(event.scrubbed)
40
+ end
41
+
42
+ def capabilities
43
+ super.merge(
44
+ breadcrumbs: false,
45
+ code_context: true,
46
+ data_scrubbing: true
47
+ )
48
+ end
49
+
50
+ private
51
+
52
+ def setup_endpoint
53
+ uri = URI.parse(@endpoint)
54
+ @api_endpoint = "#{uri.scheme}://#{uri.host}:#{uri.port}"
55
+ @api_path = "/api/#{@org}/#{@stream}/_json"
56
+ end
57
+
58
+ def build_payload(event)
59
+ # OpenObserve expects JSON array of log entries
60
+ [ build_log_entry(event) ]
61
+ end
62
+
63
+ def build_log_entry(event)
64
+ entry = {
65
+ _timestamp: timestamp_microseconds(event.timestamp),
66
+ level: map_level(event.level),
67
+ logger: "lapsoss",
68
+ environment: event.environment.presence || "production",
69
+ service: @settings[:service_name].presence || "rails"
70
+ }
71
+
72
+ case event.type
73
+ when :exception
74
+ entry.merge!(build_exception_entry(event))
75
+ when :message
76
+ entry[:message] = event.message
77
+ else
78
+ entry[:message] = event.message || "Unknown event"
79
+ end
80
+
81
+ # Add optional context
82
+ entry[:user] = event.user_context if event.user_context.present?
83
+ entry[:tags] = event.tags if event.tags.present?
84
+ entry[:extra] = event.extra if event.extra.present?
85
+ entry[:request] = event.request_context if event.request_context.present?
86
+ entry[:transaction] = event.transaction if event.transaction.present?
87
+ entry[:fingerprint] = event.fingerprint if event.fingerprint.present?
88
+
89
+ entry.compact_blank
90
+ end
91
+
92
+ def build_exception_entry(event)
93
+ entry = {
94
+ message: "#{event.exception_type}: #{event.exception_message}",
95
+ exception_type: event.exception_type,
96
+ exception_message: event.exception_message
97
+ }
98
+
99
+ if event.has_backtrace?
100
+ entry[:stacktrace] = format_stacktrace(event)
101
+ entry[:stacktrace_raw] = event.backtrace_frames.map do |frame|
102
+ "#{frame.absolute_path || frame.filename}:#{frame.line_number} in `#{frame.method_name}`"
103
+ end
104
+ end
105
+
106
+ entry
107
+ end
108
+
109
+ def format_stacktrace(event)
110
+ event.backtrace_frames.map do |frame|
111
+ frame_entry = {
112
+ filename: frame.filename,
113
+ abs_path: frame.absolute_path || frame.filename,
114
+ function: frame.method_name || frame.function,
115
+ lineno: frame.line_number,
116
+ in_app: frame.in_app
117
+ }
118
+
119
+ if frame.code_context.present?
120
+ frame_entry[:context_line] = frame.code_context[:context_line]
121
+ frame_entry[:pre_context] = frame.code_context[:pre_context]
122
+ frame_entry[:post_context] = frame.code_context[:post_context]
123
+ end
124
+
125
+ frame_entry.compact
126
+ end
127
+ end
128
+
129
+ def timestamp_microseconds(time)
130
+ # OpenObserve expects _timestamp in microseconds
131
+ (time.to_f * 1_000_000).to_i
132
+ end
133
+
134
+ def serialize_payload(payload)
135
+ json = ActiveSupport::JSON.encode(payload)
136
+
137
+ if json.bytesize >= compress_threshold
138
+ [ ActiveSupport::Gzip.compress(json), true ]
139
+ else
140
+ [ json, false ]
141
+ end
142
+ end
143
+
144
+ def compress_threshold
145
+ @settings[:compress_threshold] || 1024
146
+ end
147
+
148
+ def adapter_specific_headers
149
+ credentials = Base64.strict_encode64("#{@username}:#{@password}")
150
+ {
151
+ "Authorization" => "Basic #{credentials}"
152
+ }
153
+ end
154
+ end
155
+ end
156
+ end