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.
@@ -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)
@@ -78,6 +80,11 @@ module Lapsoss
78
80
  private
79
81
 
80
82
  def capture_event(event)
83
+ if Current.silenced
84
+ @configuration.logger.debug("[LAPSOSS] Event dropped: capture silenced via Lapsoss.silence")
85
+ return nil
86
+ end
87
+
81
88
  @configuration.logger.debug("[LAPSOSS] capture_event called, async: #{@configuration.async}, executor: #{@executor.inspect}")
82
89
 
83
90
  # Apply pipeline processing if enabled
@@ -86,6 +93,11 @@ module Lapsoss
86
93
  return nil unless event
87
94
  end
88
95
 
96
+ if (filter = @configuration.exclusion_filter) && filter.should_exclude?(event)
97
+ @configuration.logger.debug("[LAPSOSS] Event excluded by configured exclusion_filter")
98
+ return nil
99
+ end
100
+
89
101
  event = run_before_send(event)
90
102
  return nil unless event
91
103
 
@@ -121,12 +133,23 @@ module Lapsoss
121
133
  end
122
134
 
123
135
  def scope_to_context(scope)
136
+ defaults = @configuration.default_context
124
137
  {
125
- tags: scope.tags,
126
- user: scope.user,
127
- extra: scope.extra,
138
+ tags: (defaults[:tags] || {}).merge(scope.tags),
139
+ user: (defaults[:user] || {}).merge(scope.user),
140
+ extra: (defaults[:extra] || {}).merge(scope.extra),
128
141
  breadcrumbs: scope.breadcrumbs
129
- }
142
+ }.tap do |ctx|
143
+ ctx[:environment] ||= @configuration.environment if @configuration.environment
144
+ end
145
+ end
146
+
147
+ def merge_context(scope_context, extra_context)
148
+ return scope_context unless extra_context
149
+
150
+ merged = scope_context.dup
151
+ merged[:context] = (scope_context[:context] || {}).merge(extra_context)
152
+ merged
130
153
  end
131
154
 
132
155
  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, :capture_rails_events, :rails_event_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
 
@@ -61,10 +62,15 @@ module Lapsoss
61
62
  @sampling_strategy = nil
62
63
  # Rails error filtering
63
64
  @skip_rails_cache_errors = true
65
+ # Rails.event structured events as breadcrumbs (Rails 8.1+)
66
+ @capture_rails_events = true
67
+ @rails_event_filter = nil
64
68
  # HTTP client settings
65
69
  @force_sync_http = false
66
70
  # Capture request context in middleware
67
71
  @capture_request_context = true
72
+ # Exclusion filter
73
+ @exclusion_filter = nil
68
74
  end
69
75
 
70
76
  # Register a named adapter configuration
@@ -114,6 +120,35 @@ module Lapsoss
114
120
  register_adapter(name, :logger, **settings)
115
121
  end
116
122
 
123
+ # Convenience method for OpenObserve
124
+ def use_openobserve(name: :openobserve, **settings)
125
+ register_adapter(name, :openobserve, **settings)
126
+ end
127
+
128
+ # Convenience method for OTLP (OpenTelemetry Protocol)
129
+ # Works with SigNoz, Jaeger, Tempo, Honeycomb, etc.
130
+ def use_otlp(name: :otlp, **settings)
131
+ register_adapter(name, :otlp, **settings)
132
+ end
133
+
134
+ # Convenience method for SigNoz (OTLP-compatible)
135
+ def use_signoz(name: :signoz, **settings)
136
+ settings[:endpoint] ||= "http://localhost:4318"
137
+ register_adapter(name, :otlp, **settings)
138
+ end
139
+
140
+ # Convenience method for Jaeger (OTLP-compatible)
141
+ def use_jaeger(name: :jaeger, **settings)
142
+ settings[:endpoint] ||= "http://localhost:4318"
143
+ register_adapter(name, :otlp, **settings)
144
+ end
145
+
146
+ # Convenience method for Grafana Tempo (OTLP-compatible)
147
+ def use_tempo(name: :tempo, **settings)
148
+ settings[:endpoint] ||= "http://localhost:4318"
149
+ register_adapter(name, :otlp, **settings)
150
+ end
151
+
117
152
  # Apply configuration by registering all adapters
118
153
  def apply!
119
154
  Registry.instance.clear!
@@ -5,6 +5,7 @@ require "active_support/all"
5
5
  module Lapsoss
6
6
  class Current < ActiveSupport::CurrentAttributes
7
7
  attribute :scope, default: -> { Scope.new }
8
+ attribute :silenced, default: false
8
9
 
9
10
  def self.with_clean_scope
10
11
  previous_scope = scope
@@ -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
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ # Subscribes to Rails' structured event reporter (Rails.event, Rails 8.1+)
5
+ # and records emitted events as Lapsoss breadcrumbs, giving error reports an
6
+ # automatic activity trail without any monkey-patching.
7
+ class RailsEventSubscriber
8
+ def emit(event)
9
+ return unless Lapsoss.client
10
+
11
+ Lapsoss.add_breadcrumb(event[:name], type: :event, **breadcrumb_metadata(event))
12
+ end
13
+
14
+ private
15
+
16
+ def breadcrumb_metadata(event)
17
+ metadata = {}
18
+ payload = serialize_payload(event[:payload])
19
+ metadata[:payload] = payload if payload
20
+ metadata[:tags] = event[:tags] if event[:tags].present?
21
+ metadata[:context] = event[:context] if event[:context].present?
22
+
23
+ if (location = event[:source_location])
24
+ metadata[:source] = "#{location[:filepath]}:#{location[:lineno]}"
25
+ end
26
+
27
+ metadata
28
+ end
29
+
30
+ # Payloads are either hashes or arbitrary event objects, which Rails passes
31
+ # through as-is and expects subscribers to serialize.
32
+ def serialize_payload(payload)
33
+ case payload
34
+ when nil, Hash
35
+ payload
36
+ else
37
+ payload.respond_to?(:serialize) ? payload.serialize : payload.inspect
38
+ end
39
+ end
40
+ end
41
+ end
@@ -39,6 +39,19 @@ module Lapsoss
39
39
  Rails.error.subscribe(Lapsoss::RailsErrorSubscriber.new)
40
40
  end
41
41
 
42
+ initializer "lapsoss.rails_event_subscriber" do
43
+ Rails.event.subscribe(Lapsoss::RailsEventSubscriber.new) do |event|
44
+ config = Lapsoss.configuration
45
+ if !config.capture_rails_events
46
+ false
47
+ elsif (filter = config.rails_event_filter)
48
+ !!filter.call(event)
49
+ else
50
+ true
51
+ end
52
+ end
53
+ end
54
+
42
55
  initializer "lapsoss.controller_transaction" do
43
56
  ActiveSupport.on_load(:action_controller) do
44
57
  require "lapsoss/rails_controller_transaction"
@@ -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