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 +7 -0
- data/lib/sciosano/breadcrumb.rb +121 -0
- data/lib/sciosano/client.rb +230 -0
- data/lib/sciosano/configuration.rb +157 -0
- data/lib/sciosano/context.rb +67 -0
- data/lib/sciosano/integrations/active_job.rb +134 -0
- data/lib/sciosano/integrations/rack.rb +128 -0
- data/lib/sciosano/integrations/rails.rb +151 -0
- data/lib/sciosano/integrations/sidekiq.rb +164 -0
- data/lib/sciosano/report.rb +175 -0
- data/lib/sciosano/version.rb +5 -0
- data/lib/sciosano.rb +213 -0
- data/sciosano.gemspec +40 -0
- metadata +163 -0
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)
|