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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +52 -0
  3. data/LICENSE +26 -0
  4. data/README.md +311 -0
  5. data/lib/brainzlab/configuration.rb +215 -0
  6. data/lib/brainzlab/context.rb +91 -0
  7. data/lib/brainzlab/instrumentation/action_mailer.rb +181 -0
  8. data/lib/brainzlab/instrumentation/active_record.rb +111 -0
  9. data/lib/brainzlab/instrumentation/delayed_job.rb +236 -0
  10. data/lib/brainzlab/instrumentation/elasticsearch.rb +210 -0
  11. data/lib/brainzlab/instrumentation/faraday.rb +182 -0
  12. data/lib/brainzlab/instrumentation/grape.rb +293 -0
  13. data/lib/brainzlab/instrumentation/graphql.rb +251 -0
  14. data/lib/brainzlab/instrumentation/httparty.rb +194 -0
  15. data/lib/brainzlab/instrumentation/mongodb.rb +187 -0
  16. data/lib/brainzlab/instrumentation/net_http.rb +109 -0
  17. data/lib/brainzlab/instrumentation/redis.rb +331 -0
  18. data/lib/brainzlab/instrumentation/sidekiq.rb +264 -0
  19. data/lib/brainzlab/instrumentation.rb +132 -0
  20. data/lib/brainzlab/pulse/client.rb +132 -0
  21. data/lib/brainzlab/pulse/instrumentation.rb +364 -0
  22. data/lib/brainzlab/pulse/propagation.rb +241 -0
  23. data/lib/brainzlab/pulse/provisioner.rb +114 -0
  24. data/lib/brainzlab/pulse/tracer.rb +111 -0
  25. data/lib/brainzlab/pulse.rb +224 -0
  26. data/lib/brainzlab/rails/log_formatter.rb +801 -0
  27. data/lib/brainzlab/rails/log_subscriber.rb +341 -0
  28. data/lib/brainzlab/rails/railtie.rb +590 -0
  29. data/lib/brainzlab/recall/buffer.rb +64 -0
  30. data/lib/brainzlab/recall/client.rb +86 -0
  31. data/lib/brainzlab/recall/logger.rb +118 -0
  32. data/lib/brainzlab/recall/provisioner.rb +113 -0
  33. data/lib/brainzlab/recall.rb +155 -0
  34. data/lib/brainzlab/reflex/breadcrumbs.rb +55 -0
  35. data/lib/brainzlab/reflex/client.rb +85 -0
  36. data/lib/brainzlab/reflex/provisioner.rb +116 -0
  37. data/lib/brainzlab/reflex.rb +374 -0
  38. data/lib/brainzlab/version.rb +5 -0
  39. data/lib/brainzlab-sdk.rb +3 -0
  40. data/lib/brainzlab.rb +140 -0
  41. data/lib/generators/brainzlab/install/install_generator.rb +61 -0
  42. data/lib/generators/brainzlab/install/templates/brainzlab.rb.tt +77 -0
  43. 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