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,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+ require_relative "../http_client"
6
+
7
+ module Lapsoss
8
+ module Adapters
9
+ class AppsignalAdapter < Base
10
+ PUSH_API_URI = "https://push.appsignal.com"
11
+ ERRORS_API_URI = "https://appsignal-endpoint.net"
12
+ JSON_CONTENT_TYPE = "application/json; charset=UTF-8"
13
+
14
+ def initialize(name, settings = {})
15
+ super(name, settings)
16
+ @push_api_key = settings[:push_api_key] || ENV["APPSIGNAL_PUSH_API_KEY"]
17
+ @frontend_api_key = settings[:frontend_api_key] || ENV["APPSIGNAL_FRONTEND_API_KEY"]
18
+ @app_name = settings[:app_name] || ENV["APPSIGNAL_APP_NAME"]
19
+ @environment = Lapsoss.configuration.environment
20
+
21
+ validate_settings!
22
+
23
+ @push_client = create_http_client(PUSH_API_URI) if @push_api_key
24
+ @errors_client = create_http_client(ERRORS_API_URI) if @frontend_api_key
25
+ end
26
+
27
+ def capture(event)
28
+ return unless enabled? && @errors_client
29
+
30
+ payload = build_error_payload(event)
31
+ return unless payload
32
+
33
+ path = "/errors?api_key=#{@frontend_api_key}"
34
+ headers = { "Content-Type" => JSON_CONTENT_TYPE }
35
+
36
+ begin
37
+ @errors_client.post(path, body: JSON.generate(payload), headers: headers)
38
+ rescue DeliveryError => e
39
+ # Log the error and potentially notify error handler
40
+ Lapsoss.configuration.logger&.error("[Lapsoss::AppsignalAdapter] Failed to deliver event: #{e.message}")
41
+ Lapsoss.configuration.error_handler&.call(e)
42
+
43
+ # Re-raise to let the caller know delivery failed
44
+ raise
45
+ end
46
+ end
47
+
48
+ def shutdown
49
+ @push_client&.shutdown
50
+ @errors_client&.shutdown
51
+ super
52
+ end
53
+
54
+ private
55
+
56
+ def build_error_payload(event)
57
+ case event.type
58
+ when :exception
59
+ build_exception_payload(event)
60
+ when :message
61
+ build_message_payload(event)
62
+ end
63
+ end
64
+
65
+ def build_exception_payload(event)
66
+ {
67
+ timestamp: event.timestamp.to_i,
68
+ namespace: event.context[:namespace] || "backend",
69
+ error: {
70
+ name: event.exception.class.name,
71
+ message: event.exception.message,
72
+ backtrace: event.exception.backtrace || []
73
+ },
74
+ tags: stringify_hash(event.context[:tags]),
75
+ params: stringify_hash(event.context[:params]),
76
+ environment: build_environment_context(event),
77
+ breadcrumbs: event.context[:breadcrumbs]
78
+ }
79
+ end
80
+
81
+ def build_message_payload(event)
82
+ # AppSignal's Error API expects exception-like structure
83
+ # Instead of creating fake exceptions, we'll structure the message properly
84
+ # but clearly indicate it's a log message, not an exception
85
+
86
+ unless [:error, :fatal, :critical].include?(event.level)
87
+ # Log when messages are dropped due to level filtering
88
+ Lapsoss.configuration.logger&.debug(
89
+ "[Lapsoss::AppsignalAdapter] Dropping message with level '#{event.level}' - " \
90
+ "AppSignal only supports :error, :fatal, and :critical levels for messages"
91
+ )
92
+ return nil
93
+ end
94
+
95
+ {
96
+ action: event.context[:action] || "log_message",
97
+ path: event.context[:path] || "/",
98
+ exception: {
99
+ # AppSignal requires exception format for messages - this isn't a real exception
100
+ # but rather a way to send structured log messages through their error API
101
+ name: "LogMessage", # Clear indication this is a log message
102
+ message: event.message,
103
+ backtrace: [] # No fake backtrace for log messages
104
+ },
105
+ tags: stringify_hash(event.context[:tags]),
106
+ params: stringify_hash(event.context[:params]),
107
+ environment: build_environment_context(event),
108
+ breadcrumbs: event.context[:breadcrumbs]
109
+ }
110
+ end
111
+
112
+ def build_environment_context(event)
113
+ {
114
+ "hostname" => Socket.gethostname,
115
+ "app_name" => @app_name,
116
+ "environment" => @environment
117
+ }.merge(stringify_hash(event.context[:environment] || {}))
118
+ end
119
+
120
+ def stringify_hash(hash)
121
+ (hash || {}).transform_keys(&:to_s).transform_values(&:to_s)
122
+ end
123
+
124
+ def validate_settings!
125
+ unless @push_api_key || @frontend_api_key
126
+ raise ValidationError, "AppSignal API key is required (either push_api_key or frontend_api_key)"
127
+ end
128
+
129
+ validate_api_key!(@push_api_key, "AppSignal push API key", format: :uuid) if @push_api_key
130
+ validate_api_key!(@frontend_api_key, "AppSignal frontend API key", format: :uuid) if @frontend_api_key
131
+ validate_presence!(@app_name, "AppSignal app name") if @app_name
132
+ validate_environment!(@environment, "AppSignal environment") if @environment
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../http_client"
4
+ require_relative "../validators"
5
+
6
+ module Lapsoss
7
+ module Adapters
8
+ class Base
9
+ include Validators
10
+
11
+ attr_reader :name, :settings
12
+
13
+ def initialize(name, settings = {})
14
+ @name = name
15
+ @settings = settings
16
+ @enabled = true
17
+ configure_sdk
18
+ end
19
+
20
+ def enabled?
21
+ @enabled
22
+ end
23
+
24
+ def enable!
25
+ @enabled = true
26
+ end
27
+
28
+ def disable!
29
+ @enabled = false
30
+ end
31
+
32
+ def capabilities
33
+ {
34
+ errors: true,
35
+ performance: false,
36
+ sessions: false,
37
+ feature_flags: false,
38
+ check_ins: false,
39
+ breadcrumbs: false,
40
+ deployments: false,
41
+ metrics: false,
42
+ profiling: false,
43
+ security: false,
44
+ code_context: false,
45
+ data_scrubbing: false
46
+ }
47
+ end
48
+
49
+ def supports?(capability)
50
+ capabilities[capability] == true
51
+ end
52
+
53
+ def capture(event)
54
+ raise NotImplementedError, "#{self.class.name} must implement #capture"
55
+ end
56
+
57
+ def flush(timeout: 2)
58
+ # Optional implementation for flushing pending events
59
+ end
60
+
61
+ def shutdown
62
+ @enabled = false
63
+ end
64
+
65
+ private
66
+
67
+ def configure_sdk
68
+ # Override in subclass to configure vendor SDK
69
+ end
70
+
71
+ def create_http_client(uri, custom_config = {})
72
+ config = Lapsoss.configuration
73
+
74
+ transport_config = {
75
+ timeout: config.transport_timeout,
76
+ max_retries: config.transport_max_retries,
77
+ initial_backoff: config.transport_initial_backoff,
78
+ max_backoff: config.transport_max_backoff,
79
+ backoff_multiplier: config.transport_backoff_multiplier,
80
+ jitter: config.transport_jitter,
81
+ ssl_verify: config.transport_ssl_verify
82
+ }.merge(custom_config)
83
+
84
+ HttpClient.new(uri, transport_config)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+ require_relative "../http_client"
6
+ require_relative "../backtrace_processor"
7
+
8
+ module Lapsoss
9
+ module Adapters
10
+ # Adapter for Insight Hub (formerly Bugsnag)
11
+ # Note: The API endpoints still use bugsnag.com domains for backwards compatibility
12
+ class InsightHubAdapter < Base
13
+ NOTIFY_URI = "https://notify.bugsnag.com"
14
+ SESSION_URI = "https://sessions.bugsnag.com"
15
+ JSON_CONTENT_TYPE = "application/json"
16
+
17
+ def initialize(name, settings = {})
18
+ super(name, settings)
19
+ @api_key = settings[:api_key] || ENV["INSIGHT_HUB_API_KEY"] || ENV["BUGSNAG_API_KEY"]
20
+ @release_stage = settings[:release_stage] || Lapsoss.configuration.environment
21
+ @app_version = settings[:app_version]
22
+ @app_type = settings[:app_type] || "ruby"
23
+
24
+ validate_settings!
25
+
26
+ @notify_client = create_http_client(NOTIFY_URI)
27
+ @session_client = create_http_client(SESSION_URI) if settings[:enable_sessions]
28
+ @backtrace_processor = BacktraceProcessor.new
29
+ end
30
+
31
+ def capture(event)
32
+ return unless enabled?
33
+
34
+ payload = build_payload(event)
35
+ return unless payload
36
+
37
+ headers = {
38
+ "Content-Type" => JSON_CONTENT_TYPE,
39
+ "Bugsnag-Api-Key" => @api_key,
40
+ "Bugsnag-Payload-Version" => "5.0",
41
+ "Bugsnag-Sent-At" => Time.now.utc.iso8601
42
+ }
43
+
44
+ begin
45
+ @notify_client.post("/", body: JSON.generate(payload), headers: headers)
46
+ rescue DeliveryError => e
47
+ # Log the error and potentially notify error handler
48
+ Lapsoss.configuration.logger&.error("[Lapsoss::InsightHubAdapter] Failed to deliver event: #{e.message}")
49
+ Lapsoss.configuration.error_handler&.call(e)
50
+
51
+ # Re-raise to let the caller know delivery failed
52
+ raise
53
+ end
54
+ end
55
+
56
+ def shutdown
57
+ @notify_client&.shutdown
58
+ @session_client&.shutdown
59
+ super
60
+ end
61
+
62
+ def capabilities
63
+ super.merge(
64
+ code_context: true,
65
+ breadcrumbs: true,
66
+ sessions: true
67
+ )
68
+ end
69
+
70
+ private
71
+
72
+ def build_payload(event)
73
+ {
74
+ apiKey: @api_key,
75
+ payloadVersion: "5.0",
76
+ notifier: {
77
+ name: "Lapsoss",
78
+ version: Lapsoss::VERSION,
79
+ url: "https://github.com/seuros/lapsoss"
80
+ },
81
+ events: [build_event(event)]
82
+ }
83
+ end
84
+
85
+ def build_event(event)
86
+ base_event = {
87
+ app: {
88
+ version: @app_version,
89
+ releaseStage: @release_stage,
90
+ type: @app_type
91
+ },
92
+ device: {
93
+ hostname: Socket.gethostname,
94
+ runtimeVersions: {
95
+ ruby: RUBY_VERSION
96
+ }
97
+ },
98
+ severity: map_severity(event.level),
99
+ unhandled: event.context[:unhandled] || false,
100
+ severityReason: {
101
+ type: event.context[:unhandled] ? "unhandledException" : "handledException"
102
+ },
103
+ user: build_user(event),
104
+ context: event.context[:context],
105
+ groupingHash: event.context[:fingerprint],
106
+ breadcrumbs: build_breadcrumbs(event),
107
+ metaData: event.context[:extra] || {}
108
+ }
109
+
110
+ case event.type
111
+ when :exception
112
+ base_event.merge(build_exception_data(event))
113
+ when :message
114
+ base_event.merge(build_message_data(event))
115
+ end
116
+ end
117
+
118
+ def build_exception_data(event)
119
+ {
120
+ exceptions: [{
121
+ errorClass: event.exception.class.name,
122
+ message: event.exception.message,
123
+ stacktrace: build_stacktrace(event.exception),
124
+ type: "ruby"
125
+ }]
126
+ }
127
+ end
128
+
129
+ def build_message_data(event)
130
+ {
131
+ exceptions: [{
132
+ # Insight Hub (Bugsnag) requires exception format for messages - this isn't a real exception
133
+ # but rather a way to send structured log messages through their error API
134
+ errorClass: "LogMessage", # Clear indication this is a log message
135
+ message: event.message,
136
+ stacktrace: [], # No fake backtrace for log messages
137
+ type: "log" # Mark as log type, not ruby exception
138
+ }]
139
+ }
140
+ end
141
+
142
+ def build_stacktrace(exception)
143
+ return [] unless exception
144
+
145
+ frames = @backtrace_processor.process_exception(exception)
146
+ @backtrace_processor.format_frames(frames, :bugsnag)
147
+ end
148
+
149
+
150
+ def build_breadcrumbs(event)
151
+ breadcrumbs = event.context[:breadcrumbs] || []
152
+ breadcrumbs.map do |crumb|
153
+ {
154
+ timestamp: crumb[:timestamp]&.iso8601 || Time.now.utc.iso8601,
155
+ name: crumb[:message] || crumb[:name],
156
+ type: crumb[:type] || "manual",
157
+ metaData: crumb[:data] || {}
158
+ }
159
+ end
160
+ end
161
+
162
+ def build_user(event)
163
+ user = event.context[:user]
164
+ return unless user
165
+
166
+ {
167
+ id: user[:id]&.to_s,
168
+ name: user[:username] || user[:name],
169
+ email: user[:email]
170
+ }.compact
171
+ end
172
+
173
+ def map_severity(level)
174
+ case level
175
+ when :debug, :info then "info"
176
+ when :warning, :warn then "warning"
177
+ when :error, :fatal, :critical then "error"
178
+ else "error"
179
+ end
180
+ end
181
+
182
+ def validate_settings!
183
+ validate_presence!(@api_key, "Insight Hub API key")
184
+ validate_api_key!(@api_key, "Insight Hub API key", format: :alphanumeric) if @api_key
185
+ validate_environment!(@app_version, "Insight Hub app version") if @app_version
186
+ validate_type!(@release_stage, [String, Symbol], "Insight Hub release stage") if @release_stage
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Lapsoss
6
+ module Adapters
7
+ class LoggerAdapter < Base
8
+ def initialize(name, settings = {})
9
+ @logger = settings[:logger] || Logger.new($stdout)
10
+ super(name, settings)
11
+ end
12
+
13
+ def capabilities
14
+ super.merge(
15
+ breadcrumbs: true
16
+ )
17
+ end
18
+
19
+ def capture(event)
20
+ case event.type
21
+ when :exception
22
+ @logger.error(format_exception(event.exception, event.context))
23
+ when :message
24
+ logger_level = map_level(event.level)
25
+ @logger.send(logger_level, format_message(event.message, event.context))
26
+ else
27
+ @logger.info("[LAPSOSS] Unhandled event type: #{event.type.inspect} | Event: #{event.to_h.inspect}")
28
+ end
29
+
30
+ # Log breadcrumbs if present in the event context
31
+ if event.context[:breadcrumbs]&.any?
32
+ event.context[:breadcrumbs].each do |breadcrumb|
33
+ breadcrumb_msg = "[BREADCRUMB] [#{breadcrumb[:type].upcase}] #{breadcrumb[:message]}"
34
+ breadcrumb_msg += " | #{breadcrumb[:metadata].inspect}" unless breadcrumb[:metadata].empty?
35
+ @logger.debug(breadcrumb_msg)
36
+ end
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def format_exception(exception, context)
43
+ message = "[LAPSOSS] Exception: #{exception.class}: #{exception.message}"
44
+ message += "\n#{exception.backtrace.first(10).join("\n")}" if exception.backtrace
45
+ message += "\nContext: #{context.inspect}" unless context.empty?
46
+ message
47
+ end
48
+
49
+ def format_message(message, context)
50
+ msg = "[LAPSOSS] #{message}"
51
+ msg += " | Context: #{context.inspect}" unless context.empty?
52
+ msg
53
+ end
54
+
55
+ def map_level(level)
56
+ case level
57
+ when :debug then :debug
58
+ when :info then :info
59
+ when :warn, :warning then :warn
60
+ when :error then :error
61
+ when :fatal then :fatal
62
+ else :info
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+ require "securerandom"
6
+ require_relative "../http_client"
7
+ require_relative "../backtrace_processor"
8
+
9
+ module Lapsoss
10
+ module Adapters
11
+ class RollbarAdapter < Base
12
+ API_URI = "https://api.rollbar.com"
13
+ API_VERSION = "1"
14
+ JSON_CONTENT_TYPE = "application/json"
15
+
16
+ def initialize(name, settings = {})
17
+ super(name, settings)
18
+ @access_token = settings[:access_token] || ENV["ROLLBAR_ACCESS_TOKEN"]
19
+ @environment = settings[:environment] || Lapsoss.configuration.environment || "development"
20
+
21
+ validate_settings!
22
+
23
+ @client = create_http_client(API_URI)
24
+ @backtrace_processor = BacktraceProcessor.new
25
+ end
26
+
27
+ def capture(event)
28
+ return unless enabled?
29
+
30
+ payload = build_payload(event)
31
+ return unless payload
32
+
33
+ path = "/api/#{API_VERSION}/item/"
34
+ headers = {
35
+ "Content-Type" => JSON_CONTENT_TYPE,
36
+ "X-Rollbar-Access-Token" => @access_token
37
+ }
38
+
39
+ begin
40
+ @client.post(path, body: JSON.generate(payload), headers: headers)
41
+ rescue DeliveryError => e
42
+ # Log the error and potentially notify error handler
43
+ Lapsoss.configuration.logger&.error("[Lapsoss::RollbarAdapter] Failed to deliver event: #{e.message}")
44
+ Lapsoss.configuration.error_handler&.call(e)
45
+
46
+ # Re-raise to let the caller know delivery failed
47
+ raise
48
+ end
49
+ end
50
+
51
+ def shutdown
52
+ @client&.shutdown
53
+ super
54
+ end
55
+
56
+ def capabilities
57
+ super.merge(
58
+ code_context: true
59
+ )
60
+ end
61
+
62
+ private
63
+
64
+ def build_payload(event)
65
+ data = {
66
+ environment: @environment,
67
+ body: build_body(event),
68
+ level: map_level(event.level),
69
+ timestamp: event.timestamp.to_i,
70
+ platform: "ruby",
71
+ language: "ruby",
72
+ framework: detect_framework,
73
+ context: event.context[:context],
74
+ request: event.context[:request],
75
+ person: build_person(event),
76
+ custom: event.context[:extra] || {},
77
+ title: event.message || (event.exception&.message if event.type == :exception),
78
+ uuid: SecureRandom.uuid,
79
+ notifier: {
80
+ name: "lapsoss",
81
+ version: Lapsoss::VERSION
82
+ }
83
+ }
84
+
85
+ # Only add fingerprint if it exists
86
+ data[:fingerprint] = event.context[:fingerprint].to_s if event.context[:fingerprint]
87
+
88
+ { data: data }
89
+ end
90
+
91
+ def build_body(event)
92
+ case event.type
93
+ when :exception
94
+ {
95
+ trace: {
96
+ frames: build_backtrace_frames(event.exception),
97
+ exception: {
98
+ class: event.exception.class.name,
99
+ message: event.exception.message,
100
+ description: event.exception.message
101
+ }
102
+ }
103
+ }
104
+ when :message
105
+ {
106
+ message: {
107
+ body: event.message
108
+ }
109
+ }
110
+ end
111
+ end
112
+
113
+ def build_backtrace_frames(exception)
114
+ return [] unless exception
115
+
116
+ frames = @backtrace_processor.process_exception(exception, follow_cause: true)
117
+ formatted_frames = @backtrace_processor.format_frames(frames, :rollbar)
118
+ # Rollbar expects frames in reverse order (most recent first)
119
+ formatted_frames.reverse
120
+ end
121
+
122
+ def build_person(event)
123
+ user = event.context[:user]
124
+ return unless user
125
+
126
+ {
127
+ id: user[:id]&.to_s,
128
+ username: user[:username],
129
+ email: user[:email]
130
+ }.compact
131
+ end
132
+
133
+ def map_level(level)
134
+ case level
135
+ when :debug then "debug"
136
+ when :info then "info"
137
+ when :warning, :warn then "warning"
138
+ when :error then "error"
139
+ when :fatal, :critical then "critical"
140
+ else "error"
141
+ end
142
+ end
143
+
144
+ def detect_framework
145
+ return "rails" if defined?(Rails)
146
+ return "sinatra" if defined?(Sinatra)
147
+ "ruby"
148
+ end
149
+
150
+ def validate_settings!
151
+ validate_presence!(@access_token, "Rollbar access token")
152
+ validate_api_key!(@access_token, "Rollbar access token", format: :alphanumeric) if @access_token
153
+ validate_environment!(@environment, "Rollbar environment") if @environment
154
+ end
155
+ end
156
+ end
157
+ end