lapsoss 0.4.10 → 0.4.11

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: a4370921854bb3be19bd9209a54da4a763833386b0ef917c6ec5a632a2941db9
4
+ data.tar.gz: 7df3efd16d2b889561b94a76c2d8ed7b7e764ca6b2a5b45e3edf510b58bcf8bc
5
5
  SHA512:
6
- metadata.gz: ac8bc565e2d54b1c3f600de8b2a6fedc0e2da481ce3c9003c587b81bd4134209618ba77dd3ae1019188537217c09c2f8317640ea36d46b91a229116eeaafd032
7
- data.tar.gz: eee117f27719db7cfe019466cfbe5ec1210e86c0cfb0a7bff6764984acb148fb2233dd0c84fb7029e0e643e38f2147989f41172035a41dbd79a35eecad79763f
6
+ metadata.gz: 23f1efbcc3d5fb6cceb32f9d7794f1ce8943dfe51696b42c95c2bdc257acb0a6770c0470d2d72729f45e9beb7919948c46dea8db6bfb903381466c7dfd2ab771
7
+ data.tar.gz: 3fd7dbd3eca37712e7eec4fa9908939b797805d779d62f242c6c6e30f1ca7baeeb49470f1a1f7a1cd97f5d3d0320ecd116048870a2bb4d530380bcfd716243e5
data/README.md CHANGED
@@ -220,6 +220,8 @@ All adapters are pure Ruby implementations with no external SDK dependencies:
220
220
  - **AppSignal** - Error tracking and deploy markers
221
221
  - **Insight Hub** (formerly Bugsnag) - Error tracking with breadcrumbs
222
222
  - **Telebugs** - Sentry-compatible protocol (perfect for self-hosted alternatives)
223
+ - **OpenObserve** - Open-source observability platform (logs, metrics, traces)
224
+ - **OTLP** - OpenTelemetry Protocol (works with SigNoz, Jaeger, Tempo, Honeycomb, etc.)
223
225
 
224
226
  ## Configuration
225
227
 
@@ -254,6 +256,39 @@ Lapsoss.configure do |config|
254
256
  end
255
257
  ```
256
258
 
259
+ ### Using OpenObserve
260
+
261
+ ```ruby
262
+ # OpenObserve - open-source observability platform
263
+ Lapsoss.configure do |config|
264
+ config.use_openobserve(
265
+ endpoint: ENV['OPENOBSERVE_ENDPOINT'], # e.g., "http://localhost:5080"
266
+ username: ENV['OPENOBSERVE_USERNAME'],
267
+ password: ENV['OPENOBSERVE_PASSWORD'],
268
+ org: "default", # optional, defaults to "default"
269
+ stream: "errors" # optional, defaults to "errors"
270
+ )
271
+ end
272
+ ```
273
+
274
+ ### Using OTLP (OpenTelemetry Protocol)
275
+
276
+ ```ruby
277
+ # Works with SigNoz, Jaeger, Tempo, Honeycomb, Datadog, etc.
278
+ Lapsoss.configure do |config|
279
+ config.use_otlp(
280
+ endpoint: ENV['OTLP_ENDPOINT'], # e.g., "http://localhost:4318"
281
+ service_name: "my-rails-app",
282
+ headers: { "X-Custom-Header" => "value" } # optional custom headers
283
+ )
284
+
285
+ # Or use convenience helpers for specific services
286
+ config.use_signoz(signoz_api_key: ENV['SIGNOZ_API_KEY'])
287
+ config.use_jaeger(endpoint: "http://jaeger:4318")
288
+ config.use_tempo(endpoint: "http://tempo:4318")
289
+ end
290
+ ```
291
+
257
292
  ### Advanced Configuration
258
293
 
259
294
  ```ruby
@@ -276,6 +311,22 @@ Lapsoss.configure do |config|
276
311
  end
277
312
  ```
278
313
 
314
+ ### Pipeline & Sampling (optional)
315
+
316
+ ```ruby
317
+ Lapsoss.configure do |config|
318
+ # Build a middleware pipeline for every event
319
+ config.configure_pipeline do |pipeline|
320
+ pipeline.sample(rate: 0.1) # Drop 90% of events
321
+
322
+ pipeline.enhance_user_context(
323
+ provider: ->(event, _) { current_user&.slice(:id, :email) },
324
+ privacy_mode: true # keep only ids
325
+ )
326
+ end
327
+ end
328
+ ```
329
+
279
330
  ### Filtering Errors
280
331
 
281
332
  You decide what errors to track. Lapsoss doesn't make assumptions:
@@ -351,6 +402,30 @@ Rails.application.config.filter_parameters += [:password, :token]
351
402
  # Lapsoss automatically uses these filters - no additional configuration needed!
352
403
  ```
353
404
 
405
+ Additional controls:
406
+
407
+ ```ruby
408
+ Lapsoss.configure do |config|
409
+ config.scrub_fields = %w[credit_card ssn api_key]
410
+ config.scrub_all = true # Mask everything by default
411
+ config.whitelist_fields = %w[user_id request_id] # Keep these fields as-is
412
+ config.randomize_scrub_length = true # Avoid fixed "[FILTERED]" marker
413
+ end
414
+ ```
415
+
416
+ ### Error Handler Hook
417
+
418
+ Get a callback when an adapter fails to deliver:
419
+
420
+ ```ruby
421
+ Lapsoss.configure do |config|
422
+ config.error_handler = lambda do |adapter, event, error|
423
+ Rails.logger.error("Delivery failed for #{adapter.name}: #{error.message}")
424
+ Rails.logger.error(event.to_h) if Rails.env.development?
425
+ end
426
+ end
427
+ ```
428
+
354
429
  ### Custom Fingerprinting
355
430
 
356
431
  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
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+
5
+ module Lapsoss
6
+ module Adapters
7
+ # OTLP adapter - sends errors via OpenTelemetry Protocol
8
+ # Works with any OTLP-compatible backend: SigNoz, Jaeger, Tempo, Honeycomb, etc.
9
+ # Docs: https://opentelemetry.io/docs/specs/otlp/
10
+ class OtlpAdapter < Base
11
+ include Concerns::HttpDelivery
12
+ include Concerns::StacktraceBuilder
13
+ include Concerns::TraceContext
14
+ include Concerns::EnvelopeBuilder
15
+
16
+ # OTLP status codes
17
+ STATUS_CODE_UNSET = 0
18
+ STATUS_CODE_OK = 1
19
+ STATUS_CODE_ERROR = 2
20
+
21
+ # OTLP span kinds
22
+ SPAN_KIND_INTERNAL = 1
23
+
24
+ DEFAULT_ENDPOINT = "http://localhost:4318"
25
+
26
+ def initialize(name, settings = {})
27
+ super
28
+
29
+ @endpoint = settings[:endpoint].presence || ENV["OTLP_ENDPOINT"] || DEFAULT_ENDPOINT
30
+ @headers = settings[:headers] || {}
31
+ @service_name = settings[:service_name].presence || ENV["OTEL_SERVICE_NAME"] || "rails"
32
+ @environment = settings[:environment].presence || ENV["OTEL_ENVIRONMENT"] || "production"
33
+
34
+ # Support common auth patterns
35
+ if (api_key = settings[:api_key].presence || ENV["OTLP_API_KEY"])
36
+ @headers["Authorization"] = "Bearer #{api_key}"
37
+ end
38
+
39
+ if (signoz_key = settings[:signoz_api_key].presence || ENV["SIGNOZ_API_KEY"])
40
+ @headers["signoz-access-token"] = signoz_key
41
+ end
42
+
43
+ setup_endpoint
44
+ end
45
+
46
+ def capture(event)
47
+ deliver(event.scrubbed)
48
+ end
49
+
50
+ def capabilities
51
+ super.merge(
52
+ breadcrumbs: false,
53
+ code_context: true,
54
+ data_scrubbing: true
55
+ )
56
+ end
57
+
58
+ private
59
+
60
+ def setup_endpoint
61
+ uri = URI.parse(@endpoint)
62
+ @api_endpoint = "#{uri.scheme}://#{uri.host}:#{uri.port}"
63
+ @api_path = "/v1/traces"
64
+ end
65
+
66
+ def build_payload(event)
67
+ {
68
+ resourceSpans: [ build_resource_spans(event) ]
69
+ }
70
+ end
71
+
72
+ def build_resource_spans(event)
73
+ {
74
+ resource: build_resource(event),
75
+ scopeSpans: [ build_scope_spans(event) ]
76
+ }
77
+ end
78
+
79
+ def build_resource(event)
80
+ attributes = [
81
+ { key: "service.name", value: { stringValue: @service_name } },
82
+ { key: "deployment.environment", value: { stringValue: event.environment.presence || @environment } },
83
+ { key: "telemetry.sdk.name", value: { stringValue: "lapsoss" } },
84
+ { key: "telemetry.sdk.version", value: { stringValue: Lapsoss::VERSION } },
85
+ { key: "telemetry.sdk.language", value: { stringValue: "ruby" } }
86
+ ]
87
+
88
+ # Add user context as resource attributes if available
89
+ if event.user_context.present?
90
+ event.user_context.each do |key, value|
91
+ attributes << { key: "user.#{key}", value: attribute_value(value) }
92
+ end
93
+ end
94
+
95
+ { attributes: attributes }
96
+ end
97
+
98
+ def build_scope_spans(event)
99
+ {
100
+ scope: {
101
+ name: "lapsoss",
102
+ version: Lapsoss::VERSION
103
+ },
104
+ spans: [ build_span(event) ]
105
+ }
106
+ end
107
+
108
+ def build_span(event)
109
+ now = timestamp_nanos(event.timestamp)
110
+ span_name = event.type == :exception ? event.exception_type : "message"
111
+
112
+ span = {
113
+ traceId: generate_trace_id,
114
+ spanId: generate_span_id,
115
+ name: span_name,
116
+ kind: SPAN_KIND_INTERNAL,
117
+ startTimeUnixNano: now.to_s,
118
+ endTimeUnixNano: now.to_s,
119
+ status: build_status(event),
120
+ attributes: build_span_attributes(event)
121
+ }
122
+
123
+ # Add exception event for exception types
124
+ if event.type == :exception
125
+ span[:events] = [ build_exception_event(event) ]
126
+ end
127
+
128
+ span
129
+ end
130
+
131
+ def build_status(event)
132
+ if event.type == :exception || event.level == :error || event.level == :fatal
133
+ { code: STATUS_CODE_ERROR, message: event.exception_message || event.message || "Error" }
134
+ else
135
+ { code: STATUS_CODE_OK }
136
+ end
137
+ end
138
+
139
+ def build_span_attributes(event)
140
+ attributes = []
141
+
142
+ # Add tags
143
+ event.tags&.each do |key, value|
144
+ attributes << { key: key.to_s, value: attribute_value(value) }
145
+ end
146
+
147
+ # Add extra data
148
+ event.extra&.each do |key, value|
149
+ attributes << { key: "extra.#{key}", value: attribute_value(value) }
150
+ end
151
+
152
+ # Add request context
153
+ if event.request_context.present?
154
+ event.request_context.each do |key, value|
155
+ attributes << { key: "http.#{key}", value: attribute_value(value) }
156
+ end
157
+ end
158
+
159
+ # Add transaction name
160
+ if event.transaction.present?
161
+ attributes << { key: "transaction.name", value: { stringValue: event.transaction } }
162
+ end
163
+
164
+ # Add fingerprint
165
+ if event.fingerprint.present?
166
+ attributes << { key: "error.fingerprint", value: { stringValue: event.fingerprint } }
167
+ end
168
+
169
+ # Add message for message events
170
+ if event.type == :message && event.message.present?
171
+ attributes << { key: "message", value: { stringValue: event.message } }
172
+ end
173
+
174
+ attributes
175
+ end
176
+
177
+ def build_exception_event(event)
178
+ attributes = [
179
+ { key: "exception.type", value: { stringValue: event.exception_type } },
180
+ { key: "exception.message", value: { stringValue: event.exception_message } }
181
+ ]
182
+
183
+ # Add stacktrace
184
+ if event.has_backtrace?
185
+ attributes << {
186
+ key: "exception.stacktrace",
187
+ value: { stringValue: build_stacktrace_string(event) }
188
+ }
189
+ end
190
+
191
+ {
192
+ name: "exception",
193
+ timeUnixNano: timestamp_nanos(event.timestamp).to_s,
194
+ attributes: attributes
195
+ }
196
+ end
197
+
198
+ # Convert Ruby value to OTLP attribute value
199
+ def attribute_value(value)
200
+ case value
201
+ when String
202
+ { stringValue: value }
203
+ when Integer
204
+ { intValue: value.to_s }
205
+ when Float
206
+ { doubleValue: value }
207
+ when TrueClass, FalseClass
208
+ { boolValue: value }
209
+ when Array
210
+ { arrayValue: { values: value.map { |v| attribute_value(v) } } }
211
+ else
212
+ { stringValue: value.to_s }
213
+ end
214
+ end
215
+
216
+ def serialize_payload(payload)
217
+ json = ActiveSupport::JSON.encode(payload)
218
+
219
+ if json.bytesize >= compress_threshold
220
+ [ ActiveSupport::Gzip.compress(json), true ]
221
+ else
222
+ [ json, false ]
223
+ end
224
+ end
225
+
226
+ def adapter_specific_headers
227
+ @headers.dup
228
+ end
229
+ end
230
+ end
231
+ end
@@ -11,11 +11,13 @@ module Lapsoss
11
11
  include Concerns::HttpDelivery
12
12
 
13
13
  self.level_mapping_type = :rollbar
14
- self.api_endpoint = "https://api.rollbar.com"
15
- self.api_path = "/api/1/item/"
14
+ DEFAULT_API_ENDPOINT = "https://api.rollbar.com"
15
+ DEFAULT_API_PATH = "/api/1/item/"
16
16
 
17
17
  def initialize(name, settings = {})
18
18
  super
19
+ @api_endpoint = DEFAULT_API_ENDPOINT
20
+ @api_path = DEFAULT_API_PATH
19
21
  @access_token = settings[:access_token].presence || ENV["ROLLBAR_ACCESS_TOKEN"]
20
22
 
21
23
  if @access_token.blank?
@@ -47,8 +47,8 @@ module Lapsoss
47
47
 
48
48
  def setup_endpoint
49
49
  uri = URI.parse(@settings[:dsn])
50
- self.class.api_endpoint = "#{uri.scheme}://#{uri.host}:#{uri.port}"
51
- self.class.api_path = build_api_path(uri)
50
+ @api_endpoint = "#{uri.scheme}://#{uri.host}:#{uri.port}"
51
+ @api_path = build_api_path(uri)
52
52
  end
53
53
 
54
54
  def build_api_path(uri)
@@ -53,8 +53,8 @@ module Lapsoss
53
53
  debug_log "[TELEBUGS ENDPOINT] Setting endpoint: #{endpoint}"
54
54
  debug_log "[TELEBUGS ENDPOINT] Setting API path: #{api_path}"
55
55
 
56
- self.class.api_endpoint = endpoint
57
- self.class.api_path = api_path
56
+ @api_endpoint = endpoint
57
+ @api_path = api_path
58
58
  end
59
59
 
60
60
  public
@@ -63,8 +63,8 @@ module Lapsoss
63
63
  def capture(event)
64
64
  debug_log "[TELEBUGS DEBUG] Capture called for event: #{event.type}"
65
65
  debug_log "[TELEBUGS DEBUG] DSN configured: #{@dsn.inspect}"
66
- debug_log "[TELEBUGS DEBUG] Endpoint: #{self.class.api_endpoint}"
67
- debug_log "[TELEBUGS DEBUG] API Path: #{self.class.api_path}"
66
+ debug_log "[TELEBUGS DEBUG] Endpoint: #{@api_endpoint}"
67
+ debug_log "[TELEBUGS DEBUG] API Path: #{@api_path}"
68
68
 
69
69
  result = super(event)
70
70
  debug_log "[TELEBUGS DEBUG] Event sent successfully, response: #{result.inspect}"
@@ -10,15 +10,16 @@ module Lapsoss
10
10
  # The Concurrent::FixedThreadPool had issues in Rails development mode
11
11
  end
12
12
 
13
- def capture_exception(exception, **context)
13
+ def capture_exception(exception, level: :error, **context)
14
14
  return nil unless @configuration.enabled
15
15
 
16
+ extra_context = context.delete(:context)
16
17
  with_scope(context) do |scope|
17
18
  event = Event.build(
18
19
  type: :exception,
19
- level: :error,
20
+ level: level,
20
21
  exception: exception,
21
- context: scope_to_context(scope),
22
+ context: merge_context(scope_to_context(scope), extra_context),
22
23
  transaction: scope.transaction_name
23
24
  )
24
25
  capture_event(event)
@@ -28,12 +29,13 @@ module Lapsoss
28
29
  def capture_message(message, level: :info, **context)
29
30
  return nil unless @configuration.enabled
30
31
 
32
+ extra_context = context.delete(:context)
31
33
  with_scope(context) do |scope|
32
34
  event = Event.build(
33
35
  type: :message,
34
36
  level: level,
35
37
  message: message,
36
- context: scope_to_context(scope),
38
+ context: merge_context(scope_to_context(scope), extra_context),
37
39
  transaction: scope.transaction_name
38
40
  )
39
41
  capture_event(event)
@@ -86,6 +88,11 @@ module Lapsoss
86
88
  return nil unless event
87
89
  end
88
90
 
91
+ if (filter = @configuration.exclusion_filter) && filter.should_exclude?(event)
92
+ @configuration.logger.debug("[LAPSOSS] Event excluded by configured exclusion_filter")
93
+ return nil
94
+ end
95
+
89
96
  event = run_before_send(event)
90
97
  return nil unless event
91
98
 
@@ -121,12 +128,23 @@ module Lapsoss
121
128
  end
122
129
 
123
130
  def scope_to_context(scope)
131
+ defaults = @configuration.default_context
124
132
  {
125
- tags: scope.tags,
126
- user: scope.user,
127
- extra: scope.extra,
133
+ tags: (defaults[:tags] || {}).merge(scope.tags),
134
+ user: (defaults[:user] || {}).merge(scope.user),
135
+ extra: (defaults[:extra] || {}).merge(scope.extra),
128
136
  breadcrumbs: scope.breadcrumbs
129
- }
137
+ }.tap do |ctx|
138
+ ctx[:environment] ||= @configuration.environment if @configuration.environment
139
+ end
140
+ end
141
+
142
+ def merge_context(scope_context, extra_context)
143
+ return scope_context unless extra_context
144
+
145
+ merged = scope_context.dup
146
+ merged[:context] = (scope_context[:context] || {}).merge(extra_context)
147
+ merged
130
148
  end
131
149
 
132
150
  def handle_capture_error(error)
@@ -13,7 +13,8 @@ module Lapsoss
13
13
  :backtrace_context_lines, :backtrace_in_app_patterns, :backtrace_exclude_patterns,
14
14
  :backtrace_strip_load_path, :backtrace_max_frames, :backtrace_enable_code_context,
15
15
  :enable_pipeline, :pipeline_builder, :sampling_strategy,
16
- :skip_rails_cache_errors, :force_sync_http, :capture_request_context
16
+ :skip_rails_cache_errors, :force_sync_http, :capture_request_context,
17
+ :exclusion_filter
17
18
  attr_reader :fingerprint_callback, :environment, :before_send, :sample_rate, :error_handler, :transport_timeout,
18
19
  :transport_max_retries, :transport_initial_backoff, :transport_max_backoff, :transport_backoff_multiplier, :transport_ssl_verify, :default_context, :adapter_configs
19
20
 
@@ -65,6 +66,8 @@ module Lapsoss
65
66
  @force_sync_http = false
66
67
  # Capture request context in middleware
67
68
  @capture_request_context = true
69
+ # Exclusion filter
70
+ @exclusion_filter = nil
68
71
  end
69
72
 
70
73
  # Register a named adapter configuration
@@ -114,6 +117,35 @@ module Lapsoss
114
117
  register_adapter(name, :logger, **settings)
115
118
  end
116
119
 
120
+ # Convenience method for OpenObserve
121
+ def use_openobserve(name: :openobserve, **settings)
122
+ register_adapter(name, :openobserve, **settings)
123
+ end
124
+
125
+ # Convenience method for OTLP (OpenTelemetry Protocol)
126
+ # Works with SigNoz, Jaeger, Tempo, Honeycomb, etc.
127
+ def use_otlp(name: :otlp, **settings)
128
+ register_adapter(name, :otlp, **settings)
129
+ end
130
+
131
+ # Convenience method for SigNoz (OTLP-compatible)
132
+ def use_signoz(name: :signoz, **settings)
133
+ settings[:endpoint] ||= "http://localhost:4318"
134
+ register_adapter(name, :otlp, **settings)
135
+ end
136
+
137
+ # Convenience method for Jaeger (OTLP-compatible)
138
+ def use_jaeger(name: :jaeger, **settings)
139
+ settings[:endpoint] ||= "http://localhost:4318"
140
+ register_adapter(name, :otlp, **settings)
141
+ end
142
+
143
+ # Convenience method for Grafana Tempo (OTLP-compatible)
144
+ def use_tempo(name: :tempo, **settings)
145
+ settings[:endpoint] ||= "http://localhost:4318"
146
+ register_adapter(name, :otlp, **settings)
147
+ end
148
+
117
149
  # Apply configuration by registering all adapters
118
150
  def apply!
119
151
  Registry.instance.clear!
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Middleware
5
+ # Drops events based on sampling strategy or rate.
6
+ class SampleFilter < Base
7
+ def initialize(app, sample_rate: 1.0, sample_callback: nil, sampler: nil)
8
+ super(app)
9
+ @sampler =
10
+ sampler ||
11
+ sample_callback ||
12
+ Sampling::UniformSampler.new(sample_rate)
13
+ end
14
+
15
+ def call(event, hint = {})
16
+ return nil unless sample?(event, hint)
17
+
18
+ @app.call(event, hint)
19
+ end
20
+
21
+ private
22
+
23
+ def sample?(event, hint)
24
+ if @sampler.respond_to?(:sample?)
25
+ @sampler.sample?(event, hint)
26
+ else
27
+ @sampler.call(event, hint)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Middleware
5
+ # Adds user info to the event context using a callable provider.
6
+ class UserContextEnhancer < Base
7
+ def initialize(app, user_provider:, privacy_mode: false)
8
+ super(app)
9
+ @user_provider = user_provider
10
+ @privacy_mode = privacy_mode
11
+ end
12
+
13
+ def call(event, hint = {})
14
+ user_data = fetch_user(event, hint)
15
+ return @app.call(event, hint) unless user_data
16
+
17
+ merged_user = (event.context[:user] || {}).merge(user_data)
18
+ merged_user = sanitize_for_privacy(merged_user) if @privacy_mode
19
+
20
+ updated_context = event.context.merge(user: merged_user)
21
+ @app.call(event.with(context: updated_context), hint)
22
+ end
23
+
24
+ private
25
+
26
+ def fetch_user(event, hint)
27
+ return nil unless @user_provider
28
+
29
+ if @user_provider.respond_to?(:call)
30
+ @user_provider.call(event, hint)
31
+ elsif @user_provider.is_a?(Hash)
32
+ @user_provider
33
+ end
34
+ rescue StandardError
35
+ nil
36
+ end
37
+
38
+ def sanitize_for_privacy(user_hash)
39
+ allowed_keys = %i[id uuid user_id]
40
+ user_hash.slice(*allowed_keys)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -29,8 +29,9 @@ module Lapsoss
29
29
  )
30
30
 
31
31
  # Call error handler if configured
32
- handler = Lapsoss.configuration.error_handler
33
- handler&.call(adapter, event, error)
32
+ handled = error.instance_variable_defined?(:@lapsoss_error_handled) &&
33
+ error.instance_variable_get(:@lapsoss_error_handled)
34
+ Lapsoss.call_error_handler(adapter: adapter, event: event, error: error) unless handled
34
35
  end
35
36
  end
36
37
  end
@@ -10,7 +10,13 @@ module Lapsoss
10
10
  passw email secret token _key crypt salt certificate otp ssn cvv cvc
11
11
  ].freeze
12
12
 
13
+ MASK = "[FILTERED]"
14
+
13
15
  def initialize(config = {})
16
+ @scrub_all = !!config[:scrub_all]
17
+ @whitelist_fields = Array(config[:whitelist_fields]).map(&:to_s)
18
+ @randomize_scrub_length = !!config[:randomize_scrub_length]
19
+
14
20
  # Combine: Rails filter parameters + custom fields (if provided)
15
21
  base_params = if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
16
22
  Rails.application.config.filter_parameters.presence || DEFAULT_SCRUB_FIELDS
@@ -24,12 +30,94 @@ module Lapsoss
24
30
  base_params
25
31
  end
26
32
 
27
- @filter = ActiveSupport::ParameterFilter.new(filter_params)
33
+ @filter = ActiveSupport::ParameterFilter.new(filter_params, mask: MASK)
28
34
  end
29
35
 
30
36
  def scrub(data)
31
37
  return data if data.nil?
32
- @filter.filter(data)
38
+
39
+ if @scrub_all
40
+ deep_scrub_all(data)
41
+ else
42
+ filtered = @filter.filter(data)
43
+ filtered = restore_whitelisted_values(data, filtered) if @whitelist_fields.any?
44
+ @randomize_scrub_length ? randomize_masks(filtered) : filtered
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def deep_scrub_all(value)
51
+ case value
52
+ when Hash
53
+ value.each_with_object(value.class.new) do |(key, val), result|
54
+ if whitelisted?(key)
55
+ result[key] = val
56
+ else
57
+ result[key] = structure_preserving_mask(val)
58
+ end
59
+ end
60
+ when Array
61
+ value.map { |item| deep_scrub_all(item) }
62
+ else
63
+ mask_value(value)
64
+ end
65
+ end
66
+
67
+ def structure_preserving_mask(value)
68
+ case value
69
+ when Hash, Array
70
+ deep_scrub_all(value)
71
+ else
72
+ mask_value(value)
73
+ end
74
+ end
75
+
76
+ def mask_value(value)
77
+ @randomize_scrub_length ? random_mask(value) : MASK
78
+ end
79
+
80
+ def random_mask(value)
81
+ length = [ [ value.to_s.length, 3 ].max, 32 ].min
82
+ "*" * length
83
+ end
84
+
85
+ def whitelisted?(key)
86
+ @whitelist_fields.include?(key.to_s)
87
+ end
88
+
89
+ def restore_whitelisted_values(original, filtered)
90
+ case filtered
91
+ when Hash
92
+ filtered.each_with_object(filtered.class.new) do |(key, val), result|
93
+ if whitelisted?(key)
94
+ result[key] = original.is_a?(Hash) ? original[key] : original
95
+ else
96
+ next_original = original.is_a?(Hash) ? original[key] : nil
97
+ result[key] = restore_whitelisted_values(next_original, val)
98
+ end
99
+ end
100
+ when Array
101
+ filtered.each_with_index.map do |val, idx|
102
+ next_original = original.is_a?(Array) ? original[idx] : nil
103
+ restore_whitelisted_values(next_original, val)
104
+ end
105
+ else
106
+ filtered
107
+ end
108
+ end
109
+
110
+ def randomize_masks(value)
111
+ case value
112
+ when Hash
113
+ value.transform_values { |v| randomize_masks(v) }
114
+ when Array
115
+ value.map { |v| randomize_masks(v) }
116
+ when String
117
+ value == MASK ? random_mask(value) : value
118
+ else
119
+ value
120
+ end
33
121
  end
34
122
  end
35
123
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lapsoss
4
- VERSION = "0.4.10"
4
+ VERSION = "0.4.11"
5
5
  end
data/lib/lapsoss.rb CHANGED
@@ -83,6 +83,24 @@ module Lapsoss
83
83
  client.flush(timeout: timeout)
84
84
  end
85
85
 
86
+ def call_error_handler(adapter:, event:, error:)
87
+ handler = configuration.error_handler
88
+ return unless handler
89
+
90
+ case handler.arity
91
+ when 3
92
+ handler.call(adapter, event, error)
93
+ when 2
94
+ handler.call(event, error)
95
+ when 1, 0, -1
96
+ handler.call(error)
97
+ else
98
+ handler.call(adapter, event, error)
99
+ end
100
+ rescue => handler_error
101
+ configuration.logger&.error("[LAPSOSS] Error handler failed: #{handler_error.message}")
102
+ end
103
+
86
104
  delegate :shutdown, to: :client
87
105
  end
88
106
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lapsoss
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.10
4
+ version: 0.4.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -221,8 +221,12 @@ files:
221
221
  - lib/lapsoss/adapters/concerns/envelope_builder.rb
222
222
  - lib/lapsoss/adapters/concerns/http_delivery.rb
223
223
  - lib/lapsoss/adapters/concerns/level_mapping.rb
224
+ - lib/lapsoss/adapters/concerns/stacktrace_builder.rb
225
+ - lib/lapsoss/adapters/concerns/trace_context.rb
224
226
  - lib/lapsoss/adapters/insight_hub_adapter.rb
225
227
  - lib/lapsoss/adapters/logger_adapter.rb
228
+ - lib/lapsoss/adapters/openobserve_adapter.rb
229
+ - lib/lapsoss/adapters/otlp_adapter.rb
226
230
  - lib/lapsoss/adapters/rollbar_adapter.rb
227
231
  - lib/lapsoss/adapters/sentry_adapter.rb
228
232
  - lib/lapsoss/adapters/telebugs_adapter.rb
@@ -247,6 +251,8 @@ files:
247
251
  - lib/lapsoss/middleware/metrics_collector.rb
248
252
  - lib/lapsoss/middleware/rate_limiter.rb
249
253
  - lib/lapsoss/middleware/release_tracker.rb
254
+ - lib/lapsoss/middleware/sample_filter.rb
255
+ - lib/lapsoss/middleware/user_context_enhancer.rb
250
256
  - lib/lapsoss/pipeline.rb
251
257
  - lib/lapsoss/pipeline_builder.rb
252
258
  - lib/lapsoss/rails_controller_context.rb