brainzlab 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/CHANGELOG.md +52 -0
- data/LICENSE +26 -0
- data/README.md +311 -0
- data/lib/brainzlab/configuration.rb +215 -0
- data/lib/brainzlab/context.rb +91 -0
- data/lib/brainzlab/instrumentation/action_mailer.rb +181 -0
- data/lib/brainzlab/instrumentation/active_record.rb +111 -0
- data/lib/brainzlab/instrumentation/delayed_job.rb +236 -0
- data/lib/brainzlab/instrumentation/elasticsearch.rb +210 -0
- data/lib/brainzlab/instrumentation/faraday.rb +182 -0
- data/lib/brainzlab/instrumentation/grape.rb +293 -0
- data/lib/brainzlab/instrumentation/graphql.rb +251 -0
- data/lib/brainzlab/instrumentation/httparty.rb +194 -0
- data/lib/brainzlab/instrumentation/mongodb.rb +187 -0
- data/lib/brainzlab/instrumentation/net_http.rb +109 -0
- data/lib/brainzlab/instrumentation/redis.rb +331 -0
- data/lib/brainzlab/instrumentation/sidekiq.rb +264 -0
- data/lib/brainzlab/instrumentation.rb +132 -0
- data/lib/brainzlab/pulse/client.rb +132 -0
- data/lib/brainzlab/pulse/instrumentation.rb +364 -0
- data/lib/brainzlab/pulse/propagation.rb +241 -0
- data/lib/brainzlab/pulse/provisioner.rb +114 -0
- data/lib/brainzlab/pulse/tracer.rb +111 -0
- data/lib/brainzlab/pulse.rb +224 -0
- data/lib/brainzlab/rails/log_formatter.rb +801 -0
- data/lib/brainzlab/rails/log_subscriber.rb +341 -0
- data/lib/brainzlab/rails/railtie.rb +590 -0
- data/lib/brainzlab/recall/buffer.rb +64 -0
- data/lib/brainzlab/recall/client.rb +86 -0
- data/lib/brainzlab/recall/logger.rb +118 -0
- data/lib/brainzlab/recall/provisioner.rb +113 -0
- data/lib/brainzlab/recall.rb +155 -0
- data/lib/brainzlab/reflex/breadcrumbs.rb +55 -0
- data/lib/brainzlab/reflex/client.rb +85 -0
- data/lib/brainzlab/reflex/provisioner.rb +116 -0
- data/lib/brainzlab/reflex.rb +374 -0
- data/lib/brainzlab/version.rb +5 -0
- data/lib/brainzlab-sdk.rb +3 -0
- data/lib/brainzlab.rb +140 -0
- data/lib/generators/brainzlab/install/install_generator.rb +61 -0
- data/lib/generators/brainzlab/install/templates/brainzlab.rb.tt +77 -0
- metadata +159 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module BrainzLab
|
|
8
|
+
module Recall
|
|
9
|
+
class Client
|
|
10
|
+
MAX_RETRIES = 3
|
|
11
|
+
RETRY_DELAY = 0.5
|
|
12
|
+
|
|
13
|
+
def initialize(config)
|
|
14
|
+
@config = config
|
|
15
|
+
@uri = URI.parse(config.recall_url)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def send_log(log_entry)
|
|
19
|
+
return unless @config.recall_enabled && @config.valid?
|
|
20
|
+
|
|
21
|
+
post("/api/v1/log", log_entry)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def send_batch(log_entries)
|
|
25
|
+
return unless @config.recall_enabled && @config.valid?
|
|
26
|
+
return if log_entries.empty?
|
|
27
|
+
|
|
28
|
+
post("/api/v1/logs", { logs: log_entries })
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def post(path, body)
|
|
34
|
+
uri = URI.join(@config.recall_url, path)
|
|
35
|
+
request = Net::HTTP::Post.new(uri)
|
|
36
|
+
request["Content-Type"] = "application/json"
|
|
37
|
+
request["Authorization"] = "Bearer #{@config.secret_key}"
|
|
38
|
+
request["User-Agent"] = "brainzlab-sdk-ruby/#{BrainzLab::VERSION}"
|
|
39
|
+
request.body = JSON.generate(body)
|
|
40
|
+
|
|
41
|
+
execute_with_retry(uri, request)
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
log_error("Failed to send to Recall: #{e.message}")
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def execute_with_retry(uri, request)
|
|
48
|
+
retries = 0
|
|
49
|
+
begin
|
|
50
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
51
|
+
http.use_ssl = uri.scheme == "https"
|
|
52
|
+
http.open_timeout = 5
|
|
53
|
+
http.read_timeout = 10
|
|
54
|
+
|
|
55
|
+
response = http.request(request)
|
|
56
|
+
|
|
57
|
+
case response.code.to_i
|
|
58
|
+
when 200..299
|
|
59
|
+
JSON.parse(response.body) rescue {}
|
|
60
|
+
when 429, 500..599
|
|
61
|
+
raise RetryableError, "Server error: #{response.code}"
|
|
62
|
+
else
|
|
63
|
+
log_error("Recall API error: #{response.code} - #{response.body}")
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
rescue RetryableError, Net::OpenTimeout, Net::ReadTimeout => e
|
|
67
|
+
retries += 1
|
|
68
|
+
if retries <= MAX_RETRIES
|
|
69
|
+
sleep(RETRY_DELAY * retries)
|
|
70
|
+
retry
|
|
71
|
+
end
|
|
72
|
+
log_error("Failed after #{MAX_RETRIES} retries: #{e.message}")
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def log_error(message)
|
|
78
|
+
return unless @config.logger
|
|
79
|
+
|
|
80
|
+
@config.logger.error("[BrainzLab] #{message}")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class RetryableError < StandardError; end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module BrainzLab
|
|
6
|
+
module Recall
|
|
7
|
+
class Logger < ::Logger
|
|
8
|
+
attr_accessor :broadcast_to
|
|
9
|
+
|
|
10
|
+
def initialize(service_name = nil, broadcast_to: nil)
|
|
11
|
+
super(nil)
|
|
12
|
+
@service_name = service_name
|
|
13
|
+
@broadcast_to = broadcast_to
|
|
14
|
+
@level = ::Logger::DEBUG
|
|
15
|
+
@formatter = proc { |severity, _time, _progname, msg| msg }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add(severity, message = nil, progname = nil, &block)
|
|
19
|
+
severity ||= ::Logger::UNKNOWN
|
|
20
|
+
|
|
21
|
+
# Handle block-based messages
|
|
22
|
+
if message.nil? && block_given?
|
|
23
|
+
message = yield
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Handle progname as message (standard Logger behavior)
|
|
27
|
+
if message.nil?
|
|
28
|
+
message = progname
|
|
29
|
+
progname = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Broadcast to original logger if configured
|
|
33
|
+
@broadcast_to&.add(severity, message, progname)
|
|
34
|
+
|
|
35
|
+
# Skip if below configured level
|
|
36
|
+
return true if severity < @level
|
|
37
|
+
|
|
38
|
+
level = severity_to_level(severity)
|
|
39
|
+
return true unless BrainzLab.configuration.level_enabled?(level)
|
|
40
|
+
|
|
41
|
+
# Extract structured data if message is a hash
|
|
42
|
+
data = {}
|
|
43
|
+
if message.is_a?(Hash)
|
|
44
|
+
data = message.dup
|
|
45
|
+
message = data.delete(:message) || data.delete(:msg) || data.to_s
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
data[:service] = @service_name if @service_name
|
|
49
|
+
data[:progname] = progname if progname
|
|
50
|
+
|
|
51
|
+
Recall.log(level, message.to_s, **data)
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def debug(message = nil, &block)
|
|
56
|
+
add(::Logger::DEBUG, message, &block)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def info(message = nil, &block)
|
|
60
|
+
add(::Logger::INFO, message, &block)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def warn(message = nil, &block)
|
|
64
|
+
add(::Logger::WARN, message, &block)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def error(message = nil, &block)
|
|
68
|
+
add(::Logger::ERROR, message, &block)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def fatal(message = nil, &block)
|
|
72
|
+
add(::Logger::FATAL, message, &block)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def unknown(message = nil, &block)
|
|
76
|
+
add(::Logger::UNKNOWN, message, &block)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Rails compatibility methods
|
|
80
|
+
def silence(severity = ::Logger::ERROR)
|
|
81
|
+
old_level = @level
|
|
82
|
+
@level = severity
|
|
83
|
+
yield self
|
|
84
|
+
ensure
|
|
85
|
+
@level = old_level
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def tagged(*tags)
|
|
89
|
+
if block_given?
|
|
90
|
+
BrainzLab.with_context(tags: tags) { yield self }
|
|
91
|
+
else
|
|
92
|
+
self
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def flush
|
|
97
|
+
Recall.flush
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def close
|
|
101
|
+
flush
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def severity_to_level(severity)
|
|
107
|
+
case severity
|
|
108
|
+
when ::Logger::DEBUG then :debug
|
|
109
|
+
when ::Logger::INFO then :info
|
|
110
|
+
when ::Logger::WARN then :warn
|
|
111
|
+
when ::Logger::ERROR then :error
|
|
112
|
+
when ::Logger::FATAL, ::Logger::UNKNOWN then :fatal
|
|
113
|
+
else :info
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module BrainzLab
|
|
9
|
+
module Recall
|
|
10
|
+
class Provisioner
|
|
11
|
+
CACHE_DIR = ENV.fetch("BRAINZLAB_CACHE_DIR") { File.join(Dir.home, ".brainzlab") }
|
|
12
|
+
|
|
13
|
+
def initialize(config)
|
|
14
|
+
@config = config
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def ensure_project!
|
|
18
|
+
return unless should_provision?
|
|
19
|
+
|
|
20
|
+
# Try cached credentials first
|
|
21
|
+
if (cached = load_cached_credentials)
|
|
22
|
+
apply_credentials(cached)
|
|
23
|
+
return cached
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Provision new project
|
|
27
|
+
project = provision_project
|
|
28
|
+
return unless project
|
|
29
|
+
|
|
30
|
+
# Cache and apply credentials
|
|
31
|
+
cache_credentials(project)
|
|
32
|
+
apply_credentials(project)
|
|
33
|
+
|
|
34
|
+
project
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def should_provision?
|
|
40
|
+
return false unless @config.recall_auto_provision
|
|
41
|
+
return false unless @config.app_name.to_s.strip.length > 0
|
|
42
|
+
return false if @config.secret_key.to_s.strip.length > 0
|
|
43
|
+
return false unless @config.recall_master_key.to_s.strip.length > 0
|
|
44
|
+
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def provision_project
|
|
49
|
+
uri = URI.parse("#{@config.recall_url}/api/v1/projects/provision")
|
|
50
|
+
request = Net::HTTP::Post.new(uri)
|
|
51
|
+
request["Content-Type"] = "application/json"
|
|
52
|
+
request["X-Master-Key"] = @config.recall_master_key
|
|
53
|
+
request["User-Agent"] = "brainzlab-sdk-ruby/#{BrainzLab::VERSION}"
|
|
54
|
+
request.body = JSON.generate({ name: @config.app_name })
|
|
55
|
+
|
|
56
|
+
response = execute(uri, request)
|
|
57
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
58
|
+
|
|
59
|
+
JSON.parse(response.body, symbolize_names: true)
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
log_error("Failed to provision Recall project: #{e.message}")
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def load_cached_credentials
|
|
66
|
+
path = cache_file_path
|
|
67
|
+
return nil unless File.exist?(path)
|
|
68
|
+
|
|
69
|
+
data = JSON.parse(File.read(path), symbolize_names: true)
|
|
70
|
+
|
|
71
|
+
# Validate cached data has required keys
|
|
72
|
+
return nil unless data[:ingest_key]
|
|
73
|
+
|
|
74
|
+
data
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
log_error("Failed to load cached credentials: #{e.message}")
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def cache_credentials(project)
|
|
81
|
+
FileUtils.mkdir_p(CACHE_DIR)
|
|
82
|
+
File.write(cache_file_path, JSON.generate(project))
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
log_error("Failed to cache credentials: #{e.message}")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def cache_file_path
|
|
88
|
+
File.join(CACHE_DIR, "#{@config.app_name}.recall.json")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def apply_credentials(project)
|
|
92
|
+
@config.secret_key = project[:ingest_key]
|
|
93
|
+
|
|
94
|
+
# Also set service name from app_name if not already set
|
|
95
|
+
@config.service ||= @config.app_name
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def execute(uri, request)
|
|
99
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
100
|
+
http.use_ssl = uri.scheme == "https"
|
|
101
|
+
http.open_timeout = 5
|
|
102
|
+
http.read_timeout = 10
|
|
103
|
+
http.request(request)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def log_error(message)
|
|
107
|
+
return unless @config.logger
|
|
108
|
+
|
|
109
|
+
@config.logger.error("[BrainzLab] #{message}")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "recall/client"
|
|
4
|
+
require_relative "recall/buffer"
|
|
5
|
+
require_relative "recall/logger"
|
|
6
|
+
require_relative "recall/provisioner"
|
|
7
|
+
|
|
8
|
+
module BrainzLab
|
|
9
|
+
module Recall
|
|
10
|
+
class << self
|
|
11
|
+
def debug(message, **data)
|
|
12
|
+
log(:debug, message, **data)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def info(message, **data)
|
|
16
|
+
log(:info, message, **data)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def warn(message, **data)
|
|
20
|
+
log(:warn, message, **data)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def error(message, **data)
|
|
24
|
+
log(:error, message, **data)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def fatal(message, **data)
|
|
28
|
+
log(:fatal, message, **data)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def log(level, message, **data)
|
|
32
|
+
config = BrainzLab.configuration
|
|
33
|
+
return unless config.recall_enabled
|
|
34
|
+
|
|
35
|
+
# Auto-provision project on first log if app_name is configured
|
|
36
|
+
ensure_provisioned!
|
|
37
|
+
|
|
38
|
+
return unless config.level_enabled?(level)
|
|
39
|
+
return unless config.valid?
|
|
40
|
+
|
|
41
|
+
entry = build_entry(level, message, data)
|
|
42
|
+
buffer.push(entry)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def ensure_provisioned!
|
|
46
|
+
return if @provisioned
|
|
47
|
+
|
|
48
|
+
@provisioned = true
|
|
49
|
+
provisioner.ensure_project!
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def provisioner
|
|
53
|
+
@provisioner ||= Provisioner.new(BrainzLab.configuration)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def time(label, **data)
|
|
57
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
58
|
+
result = yield
|
|
59
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(1)
|
|
60
|
+
|
|
61
|
+
info("#{label} (#{duration_ms}ms)", **data.merge(duration_ms: duration_ms))
|
|
62
|
+
result
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def flush
|
|
66
|
+
buffer.flush
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def logger(name = nil)
|
|
70
|
+
Logger.new(name)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def client
|
|
74
|
+
@client ||= Client.new(BrainzLab.configuration)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def buffer
|
|
78
|
+
@buffer ||= Buffer.new(BrainzLab.configuration, client)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def reset!
|
|
82
|
+
@client = nil
|
|
83
|
+
@buffer = nil
|
|
84
|
+
@provisioner = nil
|
|
85
|
+
@provisioned = false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def build_entry(level, message, data)
|
|
91
|
+
config = BrainzLab.configuration
|
|
92
|
+
context = Context.current
|
|
93
|
+
|
|
94
|
+
entry = {
|
|
95
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
96
|
+
level: level.to_s,
|
|
97
|
+
message: message.to_s
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# Add configuration context
|
|
101
|
+
entry[:environment] = config.environment if config.environment
|
|
102
|
+
entry[:service] = config.service if config.service
|
|
103
|
+
entry[:host] = config.host if config.host
|
|
104
|
+
entry[:commit] = config.commit if config.commit
|
|
105
|
+
entry[:branch] = config.branch if config.branch
|
|
106
|
+
|
|
107
|
+
# Add request context
|
|
108
|
+
entry[:request_id] = context.request_id if context.request_id
|
|
109
|
+
entry[:session_id] = context.session_id if context.session_id
|
|
110
|
+
|
|
111
|
+
# Merge context data with provided data
|
|
112
|
+
merged_data = context.data_hash.merge(scrub_data(data))
|
|
113
|
+
entry[:data] = merged_data unless merged_data.empty?
|
|
114
|
+
|
|
115
|
+
entry
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def scrub_data(data)
|
|
119
|
+
return data if BrainzLab.configuration.scrub_fields.empty?
|
|
120
|
+
|
|
121
|
+
scrub_fields = BrainzLab.configuration.scrub_fields
|
|
122
|
+
deep_scrub(data, scrub_fields)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def deep_scrub(obj, fields)
|
|
126
|
+
case obj
|
|
127
|
+
when Hash
|
|
128
|
+
obj.each_with_object({}) do |(key, value), result|
|
|
129
|
+
if should_scrub?(key, fields)
|
|
130
|
+
result[key] = "[FILTERED]"
|
|
131
|
+
else
|
|
132
|
+
result[key] = deep_scrub(value, fields)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
when Array
|
|
136
|
+
obj.map { |item| deep_scrub(item, fields) }
|
|
137
|
+
else
|
|
138
|
+
obj
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def should_scrub?(key, fields)
|
|
143
|
+
key_str = key.to_s.downcase
|
|
144
|
+
fields.any? do |field|
|
|
145
|
+
case field
|
|
146
|
+
when Regexp
|
|
147
|
+
key_str.match?(field)
|
|
148
|
+
else
|
|
149
|
+
key_str == field.to_s.downcase
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BrainzLab
|
|
4
|
+
module Reflex
|
|
5
|
+
class Breadcrumbs
|
|
6
|
+
MAX_BREADCRUMBS = 50
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@breadcrumbs = []
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def add(message:, category: "default", level: :info, data: nil)
|
|
14
|
+
crumb = {
|
|
15
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
16
|
+
message: message.to_s,
|
|
17
|
+
category: category.to_s,
|
|
18
|
+
level: level.to_s
|
|
19
|
+
}
|
|
20
|
+
crumb[:data] = data if data
|
|
21
|
+
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
@breadcrumbs << crumb
|
|
24
|
+
@breadcrumbs.shift if @breadcrumbs.size > MAX_BREADCRUMBS
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_a
|
|
29
|
+
@mutex.synchronize { @breadcrumbs.dup }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def clear!
|
|
33
|
+
@mutex.synchronize { @breadcrumbs.clear }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def size
|
|
37
|
+
@mutex.synchronize { @breadcrumbs.size }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
def breadcrumbs
|
|
43
|
+
Context.current.breadcrumbs
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def add_breadcrumb(message, category: "default", level: :info, data: nil)
|
|
47
|
+
breadcrumbs.add(message: message, category: category, level: level, data: data)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def clear_breadcrumbs!
|
|
51
|
+
breadcrumbs.clear!
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module BrainzLab
|
|
8
|
+
module Reflex
|
|
9
|
+
class Client
|
|
10
|
+
MAX_RETRIES = 3
|
|
11
|
+
RETRY_DELAY = 0.5
|
|
12
|
+
|
|
13
|
+
def initialize(config)
|
|
14
|
+
@config = config
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def send_error(payload)
|
|
18
|
+
return unless @config.reflex_enabled && @config.reflex_valid?
|
|
19
|
+
|
|
20
|
+
post("/api/v1/errors", payload)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def send_batch(payloads)
|
|
24
|
+
return unless @config.reflex_enabled && @config.reflex_valid?
|
|
25
|
+
return if payloads.empty?
|
|
26
|
+
|
|
27
|
+
post("/api/v1/errors/batch", { errors: payloads })
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def post(path, body)
|
|
33
|
+
uri = URI.join(@config.reflex_url, path)
|
|
34
|
+
request = Net::HTTP::Post.new(uri)
|
|
35
|
+
request["Content-Type"] = "application/json"
|
|
36
|
+
request["Authorization"] = "Bearer #{@config.reflex_auth_key}"
|
|
37
|
+
request["User-Agent"] = "brainzlab-sdk-ruby/#{BrainzLab::VERSION}"
|
|
38
|
+
request.body = JSON.generate(body)
|
|
39
|
+
|
|
40
|
+
execute_with_retry(uri, request)
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
log_error("Failed to send to Reflex: #{e.message}")
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def execute_with_retry(uri, request)
|
|
47
|
+
retries = 0
|
|
48
|
+
begin
|
|
49
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
50
|
+
http.use_ssl = uri.scheme == "https"
|
|
51
|
+
http.open_timeout = 5
|
|
52
|
+
http.read_timeout = 10
|
|
53
|
+
|
|
54
|
+
response = http.request(request)
|
|
55
|
+
|
|
56
|
+
case response.code.to_i
|
|
57
|
+
when 200..299
|
|
58
|
+
JSON.parse(response.body) rescue {}
|
|
59
|
+
when 429, 500..599
|
|
60
|
+
raise RetryableError, "Server error: #{response.code}"
|
|
61
|
+
else
|
|
62
|
+
log_error("Reflex API error: #{response.code} - #{response.body}")
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
rescue RetryableError, Net::OpenTimeout, Net::ReadTimeout => e
|
|
66
|
+
retries += 1
|
|
67
|
+
if retries <= MAX_RETRIES
|
|
68
|
+
sleep(RETRY_DELAY * retries)
|
|
69
|
+
retry
|
|
70
|
+
end
|
|
71
|
+
log_error("Failed after #{MAX_RETRIES} retries: #{e.message}")
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def log_error(message)
|
|
77
|
+
return unless @config.logger
|
|
78
|
+
|
|
79
|
+
@config.logger.error("[BrainzLab::Reflex] #{message}")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class RetryableError < StandardError; end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|