sciosano 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 73fb2925974df5b63181c7c779e858925c9fb9c3436ff52552c5c0b0f1728cc9
4
+ data.tar.gz: 44e45cdcd6ee30964525ca43b548a9ecadad625d8f87a208011bebd39ad5dc51
5
+ SHA512:
6
+ metadata.gz: 4458052c4fe5918de076b99fabfb99d20077dd8671ee18f8417581eb2449f1ca39c485400f48e35790842f40c279a791651d6c44a702ac32a17b34f5cf70ddaf
7
+ data.tar.gz: 6d76b282a9910a5a74743fff810aa53312163a167fb9acfef119856943c052048f21a0a6025005665d9f74844ab2a4fc828c24fbd0d3f715228b4cf3532a8185
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sciosano
4
+ # Breadcrumb represents a single user action or event
5
+ class Breadcrumb
6
+ TYPES = %i[default http navigation click console custom].freeze
7
+
8
+ attr_reader :type, :category, :message, :data, :timestamp
9
+
10
+ def initialize(type: :default, category:, message:, data: {}, timestamp: nil)
11
+ @type = TYPES.include?(type) ? type : :default
12
+ @category = category.to_s
13
+ @message = message.to_s
14
+ @data = data
15
+ @timestamp = timestamp || Time.now.utc.iso8601(3)
16
+ end
17
+
18
+ # Convert to hash for API
19
+ #
20
+ # @return [Hash]
21
+ def to_h
22
+ {
23
+ type: type,
24
+ category: category,
25
+ message: message,
26
+ data: data,
27
+ timestamp: timestamp
28
+ }
29
+ end
30
+
31
+ # Create HTTP request breadcrumb
32
+ #
33
+ # @param method [String] HTTP method
34
+ # @param url [String] Request URL
35
+ # @param status_code [Integer] Response status
36
+ # @param duration [Float] Request duration in ms
37
+ # @return [Breadcrumb]
38
+ def self.http(method:, url:, status_code: nil, duration: nil)
39
+ new(
40
+ type: :http,
41
+ category: "http",
42
+ message: "#{method.upcase} #{url}",
43
+ data: {
44
+ method: method.upcase,
45
+ url: url,
46
+ status_code: status_code,
47
+ duration: duration
48
+ }.compact
49
+ )
50
+ end
51
+
52
+ # Create navigation breadcrumb
53
+ #
54
+ # @param from [String] Previous URL/path
55
+ # @param to [String] New URL/path
56
+ # @return [Breadcrumb]
57
+ def self.navigation(from:, to:)
58
+ new(
59
+ type: :navigation,
60
+ category: "navigation",
61
+ message: "Navigated to #{to}",
62
+ data: { from: from, to: to }
63
+ )
64
+ end
65
+
66
+ # Create console/log breadcrumb
67
+ #
68
+ # @param level [Symbol] Log level
69
+ # @param message [String] Log message
70
+ # @return [Breadcrumb]
71
+ def self.console(level:, message:)
72
+ new(
73
+ type: :console,
74
+ category: "console",
75
+ message: message.to_s[0..500],
76
+ data: { level: level }
77
+ )
78
+ end
79
+
80
+ # Create ActiveRecord query breadcrumb
81
+ #
82
+ # @param sql [String] SQL query
83
+ # @param name [String] Query name
84
+ # @param duration [Float] Query duration in ms
85
+ # @return [Breadcrumb]
86
+ def self.query(sql:, name: nil, duration: nil)
87
+ # Truncate long queries
88
+ truncated_sql = sql.to_s[0..500]
89
+ truncated_sql += "..." if sql.to_s.length > 500
90
+
91
+ new(
92
+ type: :default,
93
+ category: "query",
94
+ message: name || "SQL Query",
95
+ data: {
96
+ sql: truncated_sql,
97
+ duration: duration
98
+ }.compact
99
+ )
100
+ end
101
+
102
+ # Create Sidekiq job breadcrumb
103
+ #
104
+ # @param job_class [String] Job class name
105
+ # @param job_id [String] Job ID
106
+ # @param queue [String] Queue name
107
+ # @return [Breadcrumb]
108
+ def self.sidekiq_job(job_class:, job_id:, queue:)
109
+ new(
110
+ type: :default,
111
+ category: "job",
112
+ message: "Processing #{job_class}",
113
+ data: {
114
+ job_class: job_class,
115
+ job_id: job_id,
116
+ queue: queue
117
+ }
118
+ )
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sciosano
4
+ # Main client for sending reports to sciosano API
5
+ class Client
6
+ attr_reader :configuration
7
+
8
+ def initialize(configuration)
9
+ @configuration = configuration
10
+ @configuration.validate!
11
+ @context = Context.new
12
+ @breadcrumbs = Concurrent::Array.new
13
+ @executor = Concurrent::ThreadPoolExecutor.new(
14
+ min_threads: 1,
15
+ max_threads: 5,
16
+ max_queue: 100,
17
+ fallback_policy: :caller_runs # Don't silently discard - run in caller thread if queue full
18
+ )
19
+
20
+ log("sciosano initialized with endpoint: #{configuration.endpoint}")
21
+ end
22
+
23
+ # Capture an exception
24
+ #
25
+ # @param exception [Exception]
26
+ # @param context [Hash]
27
+ # @return [String, nil] Report ID
28
+ def capture_exception(exception, context = {})
29
+ return nil unless should_capture?(exception)
30
+
31
+ report = Report.build_from_exception(
32
+ exception,
33
+ context: @context.merge(context),
34
+ breadcrumbs: @breadcrumbs.to_a,
35
+ configuration: configuration
36
+ )
37
+
38
+ send_report(report)
39
+ end
40
+
41
+ # Capture a message
42
+ #
43
+ # @param message [String]
44
+ # @param level [Symbol]
45
+ # @param context [Hash]
46
+ # @return [String, nil] Report ID
47
+ def capture_message(message, level: :info, context: {})
48
+ report = Report.build_from_message(
49
+ message,
50
+ level: level,
51
+ context: @context.merge(context),
52
+ breadcrumbs: @breadcrumbs.to_a,
53
+ configuration: configuration
54
+ )
55
+
56
+ send_report(report)
57
+ end
58
+
59
+ # Add a breadcrumb
60
+ #
61
+ # @param breadcrumb [Breadcrumb]
62
+ def add_breadcrumb(breadcrumb)
63
+ @breadcrumbs << breadcrumb
64
+
65
+ # Keep only max_breadcrumbs
66
+ while @breadcrumbs.size > configuration.max_breadcrumbs
67
+ @breadcrumbs.shift
68
+ end
69
+ end
70
+
71
+ # Set user context
72
+ def set_user(id: nil, email: nil, name: nil, **extra)
73
+ @context.user = { id: id, email: email, name: name, **extra }.compact
74
+ end
75
+
76
+ # Clear user context
77
+ def clear_user
78
+ @context.user = nil
79
+ end
80
+
81
+ # Set extra context
82
+ def set_extra(key, value)
83
+ @context.extra[key.to_s] = value
84
+ end
85
+
86
+ # Set tags
87
+ def set_tags(tags)
88
+ @context.tags.merge!(tags.transform_keys(&:to_s))
89
+ end
90
+
91
+ # Execute block with additional context
92
+ def with_context(context = {})
93
+ old_context = @context.dup
94
+ @context.merge!(context)
95
+ yield
96
+ ensure
97
+ @context = old_context
98
+ end
99
+
100
+ # Get current breadcrumbs
101
+ def breadcrumbs
102
+ @breadcrumbs.to_a
103
+ end
104
+
105
+ # Clear breadcrumbs
106
+ def clear_breadcrumbs
107
+ @breadcrumbs.clear
108
+ end
109
+
110
+ private
111
+
112
+ def should_capture?(exception)
113
+ # Check sample rate
114
+ return false if rand > configuration.sample_rate
115
+
116
+ # Check ignore patterns
117
+ exception_class = exception.class.name
118
+ exception_message = exception.message
119
+
120
+ configuration.ignore_patterns.none? do |pattern|
121
+ case pattern
122
+ when Class
123
+ exception.is_a?(pattern)
124
+ when String
125
+ exception_class == pattern || exception_message.include?(pattern)
126
+ when Regexp
127
+ pattern.match?(exception_class) || pattern.match?(exception_message)
128
+ else
129
+ false
130
+ end
131
+ end
132
+ end
133
+
134
+ def send_report(report)
135
+ # Apply before_send callback
136
+ if configuration.before_send
137
+ report = configuration.before_send.call(report)
138
+ return nil if report.nil?
139
+ end
140
+
141
+ if configuration.async
142
+ send_async(report)
143
+ else
144
+ send_sync(report)
145
+ end
146
+ end
147
+
148
+ def send_async(report)
149
+ @executor.post do
150
+ send_sync(report)
151
+ end
152
+
153
+ # Return a placeholder ID for async sends
154
+ report.id
155
+ end
156
+
157
+ def send_sync(report, retries: 3)
158
+ attempts = 0
159
+
160
+ begin
161
+ attempts += 1
162
+ response = connection.post("/reports") do |req|
163
+ req.headers["x-sciosano-api-key"] = configuration.api_key
164
+ req.headers["Content-Type"] = "application/json"
165
+ req.body = report.to_json
166
+ end
167
+
168
+ if response.success?
169
+ data = JSON.parse(response.body)
170
+ report_id = data["id"] || data["reportId"]
171
+ log("Report sent successfully: #{report_id}")
172
+ report_id
173
+ else
174
+ log("Failed to send report: #{response.status} #{response.body}", level: :error)
175
+ nil
176
+ end
177
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
178
+ if attempts < retries
179
+ sleep_time = 2**attempts # Exponential backoff: 2, 4, 8 seconds
180
+ log("Network error (attempt #{attempts}/#{retries}), retrying in #{sleep_time}s: #{e.message}", level: :warn)
181
+ sleep(sleep_time)
182
+ retry
183
+ end
184
+ log("Failed after #{retries} attempts: #{e.message}", level: :error)
185
+ nil
186
+ rescue Faraday::Error => e
187
+ log("Network error sending report: #{e.message}", level: :error)
188
+ nil
189
+ end
190
+ end
191
+
192
+ def connection
193
+ @connection ||= Faraday.new(url: configuration.endpoint) do |f|
194
+ f.options.timeout = configuration.timeout
195
+ f.options.open_timeout = configuration.timeout
196
+ f.adapter Faraday.default_adapter
197
+ end
198
+ end
199
+
200
+ def log(message, level: :info)
201
+ formatted = "[Sciosano] #{message}"
202
+
203
+ # Always log errors and warnings (important for production visibility)
204
+ # Only gate debug/info messages behind configuration.debug
205
+ case level
206
+ when :error
207
+ if defined?(Rails.logger) && Rails.logger
208
+ Rails.logger.error(formatted)
209
+ else
210
+ warn "#{formatted}"
211
+ end
212
+ when :warn
213
+ if defined?(Rails.logger) && Rails.logger
214
+ Rails.logger.warn(formatted)
215
+ else
216
+ warn "#{formatted}"
217
+ end
218
+ else
219
+ # Debug/info messages are gated
220
+ return unless configuration.debug
221
+
222
+ if defined?(Rails.logger) && Rails.logger
223
+ Rails.logger.info(formatted)
224
+ else
225
+ puts formatted
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sciosano
4
+ # Configuration for sciosano client
5
+ class Configuration
6
+ # API key for authentication (required)
7
+ # @return [String]
8
+ attr_accessor :api_key
9
+
10
+ # Application ID (optional, extracted from API key if not provided)
11
+ # @return [String, nil]
12
+ attr_accessor :app_id
13
+
14
+ # API endpoint URL
15
+ # @return [String]
16
+ attr_accessor :endpoint
17
+
18
+ # Environment name (development, staging, production)
19
+ # @return [String]
20
+ attr_accessor :environment
21
+
22
+ # Release/version identifier
23
+ # @return [String, nil]
24
+ attr_accessor :release
25
+
26
+ # Enable debug logging
27
+ # @return [Boolean]
28
+ attr_accessor :debug
29
+
30
+ # Sample rate for error capture (0.0 to 1.0)
31
+ # @return [Float]
32
+ attr_accessor :sample_rate
33
+
34
+ # Maximum breadcrumbs to keep
35
+ # @return [Integer]
36
+ attr_accessor :max_breadcrumbs
37
+
38
+ # Timeout for API requests in seconds
39
+ # @return [Integer]
40
+ attr_accessor :timeout
41
+
42
+ # Enable async sending (non-blocking)
43
+ # @return [Boolean]
44
+ attr_accessor :async
45
+
46
+ # Callback before sending report
47
+ # @return [Proc, nil]
48
+ attr_accessor :before_send
49
+
50
+ # Patterns to ignore (exception classes or message patterns)
51
+ # @return [Array<Class, String, Regexp>]
52
+ attr_accessor :ignore_patterns
53
+
54
+ # Enable Rails integration
55
+ # @return [Boolean]
56
+ attr_accessor :rails_enabled
57
+
58
+ # Enable Sidekiq integration
59
+ # @return [Boolean]
60
+ attr_accessor :sidekiq_enabled
61
+
62
+ # Enable ActiveJob integration
63
+ # @return [Boolean]
64
+ attr_accessor :active_job_enabled
65
+
66
+ # Parameters to filter from reports
67
+ # @return [Array<String, Symbol, Regexp>]
68
+ attr_accessor :filter_parameters
69
+
70
+ def initialize
71
+ @api_key = nil
72
+ @app_id = nil
73
+ @endpoint = "https://api.sciosano.com"
74
+ @environment = ENV.fetch("RAILS_ENV", ENV.fetch("RACK_ENV", "development"))
75
+ @release = nil
76
+ @debug = false
77
+ @sample_rate = 1.0
78
+ @max_breadcrumbs = 100
79
+ @timeout = 5
80
+ @async = true
81
+ @before_send = nil
82
+ @ignore_patterns = default_ignore_patterns
83
+ @rails_enabled = true
84
+ @sidekiq_enabled = true
85
+ @active_job_enabled = true
86
+ @filter_parameters = default_filter_parameters
87
+ end
88
+
89
+ # Validate configuration
90
+ #
91
+ # @raise [ConfigurationError] if configuration is invalid
92
+ def validate!
93
+ raise ConfigurationError, "API key is required" if api_key.nil? || api_key.empty?
94
+ raise ConfigurationError, "Invalid API key format" unless api_key.match?(/^sciosano_\w+_[a-zA-Z0-9]+$/)
95
+ raise ConfigurationError, "Sample rate must be between 0.0 and 1.0" unless (0.0..1.0).cover?(sample_rate)
96
+ end
97
+
98
+ # Check if configuration is valid
99
+ #
100
+ # @return [Boolean]
101
+ def valid?
102
+ validate!
103
+ true
104
+ rescue ConfigurationError
105
+ false
106
+ end
107
+
108
+ # Check if environment is development
109
+ #
110
+ # @return [Boolean]
111
+ def development?
112
+ environment == "development"
113
+ end
114
+
115
+ # Check if environment is production
116
+ #
117
+ # @return [Boolean]
118
+ def production?
119
+ environment == "production"
120
+ end
121
+
122
+ private
123
+
124
+ def default_ignore_patterns
125
+ [
126
+ # Common non-errors
127
+ "ActionController::RoutingError",
128
+ "ActionController::InvalidAuthenticityToken",
129
+ "ActionController::UnknownFormat",
130
+ "ActiveRecord::RecordNotFound",
131
+ # Signal exceptions
132
+ "SignalException",
133
+ "SystemExit",
134
+ # Bot/crawler errors
135
+ /crawl|bot|spider/i
136
+ ]
137
+ end
138
+
139
+ def default_filter_parameters
140
+ %i[
141
+ password
142
+ password_confirmation
143
+ secret
144
+ token
145
+ api_key
146
+ apikey
147
+ access_token
148
+ refresh_token
149
+ credit_card
150
+ card_number
151
+ cvv
152
+ ssn
153
+ social_security
154
+ ]
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sciosano
4
+ # Context holds user, tags, and extra data for reports
5
+ class Context
6
+ attr_accessor :user, :tags, :extra, :request
7
+
8
+ def initialize
9
+ @user = nil
10
+ @tags = {}
11
+ @extra = {}
12
+ @request = nil
13
+ end
14
+
15
+ # Merge additional context
16
+ #
17
+ # @param other [Hash, Context]
18
+ # @return [Context]
19
+ def merge(other)
20
+ dup.merge!(other)
21
+ end
22
+
23
+ # Merge additional context in place
24
+ #
25
+ # @param other [Hash, Context]
26
+ # @return [self]
27
+ def merge!(other)
28
+ case other
29
+ when Context
30
+ @user = other.user if other.user
31
+ @tags.merge!(other.tags)
32
+ @extra.merge!(other.extra)
33
+ @request = other.request if other.request
34
+ when Hash
35
+ @user = other[:user] if other[:user]
36
+ @tags.merge!(other[:tags] || {})
37
+ @extra.merge!(other[:extra] || {})
38
+ @request = other[:request] if other[:request]
39
+ end
40
+ self
41
+ end
42
+
43
+ # Duplicate context
44
+ #
45
+ # @return [Context]
46
+ def dup
47
+ new_context = Context.new
48
+ new_context.user = @user&.dup
49
+ new_context.tags = @tags.dup
50
+ new_context.extra = @extra.dup
51
+ new_context.request = @request&.dup
52
+ new_context
53
+ end
54
+
55
+ # Convert to hash
56
+ #
57
+ # @return [Hash]
58
+ def to_h
59
+ {
60
+ user: user,
61
+ tags: tags,
62
+ extra: extra,
63
+ request: request
64
+ }.compact
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sciosano
4
+ module Integrations
5
+ # ActiveJob integration for capturing job errors
6
+ module ActiveJob
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ around_perform :sciosano_capture_exceptions
11
+ after_enqueue :sciosano_enqueue_breadcrumb
12
+ end
13
+
14
+ private
15
+
16
+ def sciosano_capture_exceptions
17
+ # Add job breadcrumb
18
+ sciosano_add_job_breadcrumb
19
+
20
+ # Set job context
21
+ Sciosano.client&.with_context(
22
+ extra: {
23
+ active_job: self.class.name,
24
+ active_job_id: job_id,
25
+ active_job_queue: queue_name,
26
+ active_job_executions: executions
27
+ }
28
+ ) do
29
+ yield
30
+ end
31
+ rescue Exception => e # rubocop:disable Lint/RescueException
32
+ sciosano_capture_exception(e)
33
+ raise
34
+ end
35
+
36
+ def sciosano_add_job_breadcrumb
37
+ return unless Sciosano.client
38
+
39
+ Sciosano.add_breadcrumb(
40
+ type: :default,
41
+ category: "active_job",
42
+ message: "Processing #{self.class.name}",
43
+ data: {
44
+ job_class: self.class.name,
45
+ job_id: job_id,
46
+ queue: queue_name,
47
+ priority: priority,
48
+ executions: executions
49
+ }.compact
50
+ )
51
+ end
52
+
53
+ def sciosano_capture_exception(exception)
54
+ return unless Sciosano.client
55
+
56
+ Sciosano.capture_exception(exception, {
57
+ tags: {
58
+ "active_job.queue" => queue_name,
59
+ "active_job.job" => self.class.name
60
+ },
61
+ extra: {
62
+ active_job: {
63
+ job_class: self.class.name,
64
+ job_id: job_id,
65
+ queue: queue_name,
66
+ priority: priority,
67
+ executions: executions,
68
+ scheduled_at: scheduled_at,
69
+ arguments: sanitize_arguments(arguments)
70
+ }
71
+ }
72
+ })
73
+ end
74
+
75
+ def sciosano_enqueue_breadcrumb
76
+ return unless Sciosano.client
77
+
78
+ Sciosano.add_breadcrumb(
79
+ type: :default,
80
+ category: "active_job",
81
+ message: "Enqueued #{self.class.name}",
82
+ data: {
83
+ job_class: self.class.name,
84
+ job_id: job_id,
85
+ queue: queue_name
86
+ }
87
+ )
88
+ end
89
+
90
+ def sanitize_arguments(args)
91
+ return [] unless args.is_a?(Array)
92
+
93
+ args.map do |arg|
94
+ case arg
95
+ when Hash
96
+ filter_sensitive_hash(arg)
97
+ when String
98
+ arg.length > 200 ? "#{arg[0..200]}..." : arg
99
+ when ActiveRecord::Base
100
+ "#{arg.class.name}##{arg.id}"
101
+ else
102
+ arg.inspect[0..200]
103
+ end
104
+ end
105
+ end
106
+
107
+ def filter_sensitive_hash(hash)
108
+ hash.transform_values do |value|
109
+ case value
110
+ when Hash
111
+ filter_sensitive_hash(value)
112
+ else
113
+ value
114
+ end
115
+ end.reject do |key, _|
116
+ key.to_s.downcase.match?(/password|secret|token|key|auth|credit|card/)
117
+ end
118
+ end
119
+
120
+ # Class methods
121
+ module ClassMethods
122
+ # Disable sciosano for specific job
123
+ def skip_sciosano_capture
124
+ skip_callback :perform, :around, :sciosano_capture_exceptions
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
130
+
131
+ # Auto-include in ActiveJob::Base when loaded
132
+ ActiveSupport.on_load(:active_job) do
133
+ include Sciosano::Integrations::ActiveJob
134
+ end if defined?(ActiveSupport)