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,116 @@
|
|
|
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 Reflex
|
|
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.reflex_auto_provision
|
|
41
|
+
return false unless @config.app_name.to_s.strip.length > 0
|
|
42
|
+
# Only skip if reflex_api_key is already set (not secret_key, which may be for Recall)
|
|
43
|
+
return false if @config.reflex_api_key.to_s.strip.length > 0
|
|
44
|
+
return false unless @config.reflex_master_key.to_s.strip.length > 0
|
|
45
|
+
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def provision_project
|
|
50
|
+
uri = URI.parse("#{@config.reflex_url}/api/v1/projects/provision")
|
|
51
|
+
request = Net::HTTP::Post.new(uri)
|
|
52
|
+
request["Content-Type"] = "application/json"
|
|
53
|
+
request["X-Master-Key"] = @config.reflex_master_key
|
|
54
|
+
request["User-Agent"] = "brainzlab-sdk-ruby/#{BrainzLab::VERSION}"
|
|
55
|
+
request.body = JSON.generate({ name: @config.app_name })
|
|
56
|
+
|
|
57
|
+
response = execute(uri, request)
|
|
58
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
59
|
+
|
|
60
|
+
JSON.parse(response.body, symbolize_names: true)
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
log_error("Failed to provision Reflex project: #{e.message}")
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def load_cached_credentials
|
|
67
|
+
path = cache_file_path
|
|
68
|
+
return nil unless File.exist?(path)
|
|
69
|
+
|
|
70
|
+
data = JSON.parse(File.read(path), symbolize_names: true)
|
|
71
|
+
|
|
72
|
+
# Validate cached data has required keys
|
|
73
|
+
return nil unless data[:api_key]
|
|
74
|
+
|
|
75
|
+
data
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
log_error("Failed to load cached Reflex credentials: #{e.message}")
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def cache_credentials(project)
|
|
82
|
+
FileUtils.mkdir_p(CACHE_DIR)
|
|
83
|
+
File.write(cache_file_path, JSON.generate(project))
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
log_error("Failed to cache Reflex credentials: #{e.message}")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def cache_file_path
|
|
89
|
+
File.join(CACHE_DIR, "#{@config.app_name}.reflex.json")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def apply_credentials(project)
|
|
93
|
+
# Use reflex_api_key for Reflex if we have a separate key
|
|
94
|
+
# Otherwise fall back to shared secret_key
|
|
95
|
+
@config.reflex_api_key = project[:api_key]
|
|
96
|
+
|
|
97
|
+
# Also set service name from app_name if not already set
|
|
98
|
+
@config.service ||= @config.app_name
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def execute(uri, request)
|
|
102
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
103
|
+
http.use_ssl = uri.scheme == "https"
|
|
104
|
+
http.open_timeout = 5
|
|
105
|
+
http.read_timeout = 10
|
|
106
|
+
http.request(request)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def log_error(message)
|
|
110
|
+
return unless @config.logger
|
|
111
|
+
|
|
112
|
+
@config.logger.error("[BrainzLab::Reflex] #{message}")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "reflex/client"
|
|
4
|
+
require_relative "reflex/breadcrumbs"
|
|
5
|
+
require_relative "reflex/provisioner"
|
|
6
|
+
|
|
7
|
+
module BrainzLab
|
|
8
|
+
module Reflex
|
|
9
|
+
FILTERED_PARAMS = %w[password password_confirmation token api_key secret credit_card cvv ssn].freeze
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def capture(exception, **context)
|
|
13
|
+
return unless enabled?
|
|
14
|
+
return if capture_disabled?
|
|
15
|
+
return if excluded?(exception)
|
|
16
|
+
return if sampled_out?
|
|
17
|
+
|
|
18
|
+
# Auto-provision project on first capture if app_name is configured
|
|
19
|
+
ensure_provisioned!
|
|
20
|
+
|
|
21
|
+
return unless BrainzLab.configuration.reflex_valid?
|
|
22
|
+
|
|
23
|
+
payload = build_payload(exception, context)
|
|
24
|
+
payload = run_before_send(payload, exception)
|
|
25
|
+
return if payload.nil?
|
|
26
|
+
|
|
27
|
+
client.send_error(payload)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def capture_message(message, level: :error, **context)
|
|
31
|
+
return unless enabled?
|
|
32
|
+
return if capture_disabled?
|
|
33
|
+
return if sampled_out?
|
|
34
|
+
|
|
35
|
+
# Auto-provision project on first capture if app_name is configured
|
|
36
|
+
ensure_provisioned!
|
|
37
|
+
|
|
38
|
+
return unless BrainzLab.configuration.reflex_valid?
|
|
39
|
+
|
|
40
|
+
payload = build_message_payload(message, level, context)
|
|
41
|
+
payload = run_before_send(payload, nil)
|
|
42
|
+
return if payload.nil?
|
|
43
|
+
|
|
44
|
+
client.send_error(payload)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def ensure_provisioned!
|
|
48
|
+
return if @provisioned
|
|
49
|
+
|
|
50
|
+
@provisioned = true
|
|
51
|
+
provisioner.ensure_project!
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def provisioner
|
|
55
|
+
@provisioner ||= Provisioner.new(BrainzLab.configuration)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Temporarily disable capture within a block
|
|
59
|
+
def without_capture
|
|
60
|
+
previous = Thread.current[:brainzlab_capture_disabled]
|
|
61
|
+
Thread.current[:brainzlab_capture_disabled] = true
|
|
62
|
+
yield
|
|
63
|
+
ensure
|
|
64
|
+
Thread.current[:brainzlab_capture_disabled] = previous
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def client
|
|
68
|
+
@client ||= Client.new(BrainzLab.configuration)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def reset!
|
|
72
|
+
@client = nil
|
|
73
|
+
@provisioner = nil
|
|
74
|
+
@provisioned = false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def enabled?
|
|
80
|
+
BrainzLab.configuration.reflex_enabled
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def capture_disabled?
|
|
84
|
+
Thread.current[:brainzlab_capture_disabled] == true
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def excluded?(exception)
|
|
88
|
+
config = BrainzLab.configuration
|
|
89
|
+
config.reflex_excluded_exceptions.any? do |excluded|
|
|
90
|
+
case excluded
|
|
91
|
+
when String
|
|
92
|
+
exception.class.name == excluded || exception.class.to_s == excluded
|
|
93
|
+
when Class
|
|
94
|
+
exception.is_a?(excluded)
|
|
95
|
+
when Regexp
|
|
96
|
+
exception.class.name =~ excluded
|
|
97
|
+
else
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def sampled_out?
|
|
104
|
+
rate = BrainzLab.configuration.reflex_sample_rate
|
|
105
|
+
return false if rate.nil? || rate >= 1.0
|
|
106
|
+
|
|
107
|
+
rand > rate
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def run_before_send(payload, exception)
|
|
111
|
+
hook = BrainzLab.configuration.reflex_before_send
|
|
112
|
+
return payload unless hook
|
|
113
|
+
|
|
114
|
+
hook.call(payload, exception)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def build_payload(exception, context)
|
|
118
|
+
config = BrainzLab.configuration
|
|
119
|
+
ctx = Context.current
|
|
120
|
+
|
|
121
|
+
payload = {
|
|
122
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
123
|
+
error_class: exception.class.name,
|
|
124
|
+
message: exception.message,
|
|
125
|
+
backtrace: format_backtrace(exception.backtrace || []),
|
|
126
|
+
|
|
127
|
+
# Environment
|
|
128
|
+
environment: config.environment,
|
|
129
|
+
commit: config.commit,
|
|
130
|
+
branch: config.branch,
|
|
131
|
+
server_name: config.host,
|
|
132
|
+
|
|
133
|
+
# Request context
|
|
134
|
+
request_id: ctx.request_id
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
# Add request info if available
|
|
138
|
+
add_request_info(payload, ctx)
|
|
139
|
+
|
|
140
|
+
# Add user info
|
|
141
|
+
add_user_info(payload, ctx, context)
|
|
142
|
+
|
|
143
|
+
# Add context, tags, extra
|
|
144
|
+
add_context_data(payload, ctx, context)
|
|
145
|
+
|
|
146
|
+
# Add breadcrumbs
|
|
147
|
+
payload[:breadcrumbs] = ctx.breadcrumbs.to_a
|
|
148
|
+
|
|
149
|
+
# Add fingerprint for error grouping
|
|
150
|
+
payload[:fingerprint] = compute_fingerprint(exception, context, ctx)
|
|
151
|
+
|
|
152
|
+
payload
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def build_message_payload(message, level, context)
|
|
156
|
+
config = BrainzLab.configuration
|
|
157
|
+
ctx = Context.current
|
|
158
|
+
|
|
159
|
+
payload = {
|
|
160
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
161
|
+
error_class: "Message",
|
|
162
|
+
message: message.to_s,
|
|
163
|
+
level: level.to_s,
|
|
164
|
+
|
|
165
|
+
# Environment
|
|
166
|
+
environment: config.environment,
|
|
167
|
+
commit: config.commit,
|
|
168
|
+
branch: config.branch,
|
|
169
|
+
server_name: config.host,
|
|
170
|
+
|
|
171
|
+
# Request context
|
|
172
|
+
request_id: ctx.request_id
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
# Add request info if available
|
|
176
|
+
add_request_info(payload, ctx)
|
|
177
|
+
|
|
178
|
+
# Add user info
|
|
179
|
+
add_user_info(payload, ctx, context)
|
|
180
|
+
|
|
181
|
+
# Add context, tags, extra
|
|
182
|
+
add_context_data(payload, ctx, context)
|
|
183
|
+
|
|
184
|
+
# Add breadcrumbs
|
|
185
|
+
payload[:breadcrumbs] = ctx.breadcrumbs.to_a
|
|
186
|
+
|
|
187
|
+
payload
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def add_request_info(payload, ctx)
|
|
191
|
+
return unless ctx.request_path
|
|
192
|
+
|
|
193
|
+
payload[:request] = {
|
|
194
|
+
method: ctx.request_method,
|
|
195
|
+
path: ctx.request_path,
|
|
196
|
+
url: ctx.request_url,
|
|
197
|
+
params: filter_params(ctx.request_params),
|
|
198
|
+
headers: ctx.request_headers,
|
|
199
|
+
controller: ctx.controller,
|
|
200
|
+
action: ctx.action
|
|
201
|
+
}.compact
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def add_user_info(payload, ctx, context)
|
|
205
|
+
user = context[:user] || ctx.user
|
|
206
|
+
return if user.nil? || user.empty?
|
|
207
|
+
|
|
208
|
+
payload[:user] = {
|
|
209
|
+
id: user[:id]&.to_s,
|
|
210
|
+
email: user[:email],
|
|
211
|
+
name: user[:name]
|
|
212
|
+
}.compact
|
|
213
|
+
|
|
214
|
+
# Store additional user data
|
|
215
|
+
extra_user = user.except(:id, :email, :name)
|
|
216
|
+
payload[:user_data] = extra_user unless extra_user.empty?
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def add_context_data(payload, ctx, context)
|
|
220
|
+
# Tags from context + provided tags
|
|
221
|
+
tags = ctx.tags.merge(context[:tags] || {})
|
|
222
|
+
payload[:tags] = tags unless tags.empty?
|
|
223
|
+
|
|
224
|
+
# Extra data from context + provided extra
|
|
225
|
+
extra = ctx.data_hash.merge(context[:extra] || {})
|
|
226
|
+
extra = extra.except(:user, :tags) # Remove user and tags as they're separate
|
|
227
|
+
payload[:extra] = extra unless extra.empty?
|
|
228
|
+
|
|
229
|
+
# General context
|
|
230
|
+
payload[:context] = context.except(:user, :tags, :extra) unless context.except(:user, :tags, :extra).empty?
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def format_backtrace(backtrace)
|
|
234
|
+
backtrace.first(30).map do |line|
|
|
235
|
+
if line.is_a?(String)
|
|
236
|
+
parse_backtrace_line(line)
|
|
237
|
+
else
|
|
238
|
+
line
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def parse_backtrace_line(line)
|
|
244
|
+
# Parse "path/to/file.rb:42:in `method_name'"
|
|
245
|
+
if line =~ /\A(.+):(\d+):in `(.+)'\z/
|
|
246
|
+
{
|
|
247
|
+
file: $1,
|
|
248
|
+
line: $2.to_i,
|
|
249
|
+
function: $3,
|
|
250
|
+
in_app: in_app_frame?($1)
|
|
251
|
+
}
|
|
252
|
+
else
|
|
253
|
+
{ raw: line }
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def in_app_frame?(path)
|
|
258
|
+
return false if path.nil?
|
|
259
|
+
return false if path.include?("vendor/")
|
|
260
|
+
return false if path.include?("/gems/")
|
|
261
|
+
|
|
262
|
+
path.start_with?("app/", "lib/", "./app/", "./lib/")
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def filter_params(params)
|
|
266
|
+
return nil if params.nil?
|
|
267
|
+
|
|
268
|
+
scrub_fields = BrainzLab.configuration.scrub_fields + FILTERED_PARAMS.map(&:to_sym)
|
|
269
|
+
deep_filter(params, scrub_fields)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def deep_filter(obj, fields)
|
|
273
|
+
case obj
|
|
274
|
+
when Hash
|
|
275
|
+
obj.each_with_object({}) do |(key, value), result|
|
|
276
|
+
if should_filter?(key, fields)
|
|
277
|
+
result[key] = "[FILTERED]"
|
|
278
|
+
else
|
|
279
|
+
result[key] = deep_filter(value, fields)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
when Array
|
|
283
|
+
obj.map { |item| deep_filter(item, fields) }
|
|
284
|
+
else
|
|
285
|
+
obj
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def should_filter?(key, fields)
|
|
290
|
+
key_str = key.to_s.downcase
|
|
291
|
+
fields.any? do |field|
|
|
292
|
+
case field
|
|
293
|
+
when Regexp
|
|
294
|
+
key_str.match?(field)
|
|
295
|
+
else
|
|
296
|
+
key_str == field.to_s.downcase
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Compute fingerprint for error grouping
|
|
302
|
+
# Returns an array of strings that uniquely identify the error type
|
|
303
|
+
def compute_fingerprint(exception, context, ctx)
|
|
304
|
+
custom_callback = BrainzLab.configuration.reflex_fingerprint
|
|
305
|
+
|
|
306
|
+
if custom_callback
|
|
307
|
+
# Call user's custom fingerprint callback
|
|
308
|
+
result = custom_callback.call(exception, context, ctx)
|
|
309
|
+
|
|
310
|
+
# Normalize the result
|
|
311
|
+
case result
|
|
312
|
+
when Array
|
|
313
|
+
result.map(&:to_s)
|
|
314
|
+
when String
|
|
315
|
+
[result]
|
|
316
|
+
when nil
|
|
317
|
+
# nil means use default fingerprinting
|
|
318
|
+
default_fingerprint(exception)
|
|
319
|
+
else
|
|
320
|
+
[result.to_s]
|
|
321
|
+
end
|
|
322
|
+
else
|
|
323
|
+
default_fingerprint(exception)
|
|
324
|
+
end
|
|
325
|
+
rescue StandardError => e
|
|
326
|
+
BrainzLab.debug_log("Custom fingerprint callback failed: #{e.message}")
|
|
327
|
+
default_fingerprint(exception)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Default fingerprint: error class + first in-app frame (or first frame)
|
|
331
|
+
def default_fingerprint(exception)
|
|
332
|
+
parts = [exception.class.name]
|
|
333
|
+
|
|
334
|
+
if exception.backtrace&.any?
|
|
335
|
+
# Try to find the first in-app frame
|
|
336
|
+
in_app_frame = exception.backtrace.find { |line| in_app_line?(line) }
|
|
337
|
+
frame = in_app_frame || exception.backtrace.first
|
|
338
|
+
|
|
339
|
+
if frame
|
|
340
|
+
# Normalize the frame (remove line numbers for consistent grouping)
|
|
341
|
+
normalized = normalize_frame_for_fingerprint(frame)
|
|
342
|
+
parts << normalized if normalized
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
parts
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def in_app_line?(line)
|
|
350
|
+
return false if line.nil?
|
|
351
|
+
return false if line.include?("vendor/")
|
|
352
|
+
return false if line.include?("/gems/")
|
|
353
|
+
|
|
354
|
+
line.start_with?("app/", "lib/", "./app/", "./lib/") ||
|
|
355
|
+
line.include?("/app/") ||
|
|
356
|
+
line.include?("/lib/")
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def normalize_frame_for_fingerprint(frame)
|
|
360
|
+
return nil unless frame.is_a?(String)
|
|
361
|
+
|
|
362
|
+
# Extract file and method, normalize out line numbers
|
|
363
|
+
# "app/models/user.rb:42:in `save'" -> "app/models/user.rb:in `save'"
|
|
364
|
+
if frame =~ /\A(.+):\d+:in `(.+)'\z/
|
|
365
|
+
"#{$1}:in `#{$2}'"
|
|
366
|
+
elsif frame =~ /\A(.+):\d+\z/
|
|
367
|
+
$1
|
|
368
|
+
else
|
|
369
|
+
frame
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
data/lib/brainzlab.rb
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "brainzlab/version"
|
|
4
|
+
require_relative "brainzlab/configuration"
|
|
5
|
+
require_relative "brainzlab/context"
|
|
6
|
+
require_relative "brainzlab/recall"
|
|
7
|
+
require_relative "brainzlab/reflex"
|
|
8
|
+
require_relative "brainzlab/pulse"
|
|
9
|
+
require_relative "brainzlab/instrumentation"
|
|
10
|
+
|
|
11
|
+
module BrainzLab
|
|
12
|
+
class << self
|
|
13
|
+
def configure
|
|
14
|
+
yield(configuration) if block_given?
|
|
15
|
+
configuration
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def configuration
|
|
19
|
+
@configuration ||= Configuration.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def reset_configuration!
|
|
23
|
+
@configuration = Configuration.new
|
|
24
|
+
Recall.reset!
|
|
25
|
+
Reflex.reset!
|
|
26
|
+
Pulse.reset!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Context management
|
|
30
|
+
def set_user(id: nil, email: nil, name: nil, **extra)
|
|
31
|
+
Context.current.set_user(id: id, email: email, name: name, **extra)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def set_context(**data)
|
|
35
|
+
Context.current.set_context(**data)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def set_tags(**data)
|
|
39
|
+
Context.current.set_tags(**data)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def with_context(**data, &block)
|
|
43
|
+
Context.current.with_context(**data, &block)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def clear_context!
|
|
47
|
+
Context.clear!
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Breadcrumb helpers
|
|
51
|
+
def add_breadcrumb(message, category: "default", level: :info, data: nil)
|
|
52
|
+
Reflex.add_breadcrumb(message, category: category, level: level, data: data)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def clear_breadcrumbs!
|
|
56
|
+
Reflex.clear_breadcrumbs!
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Create a logger that can replace Rails.logger
|
|
60
|
+
# @param broadcast_to [Logger] Optional logger to also send logs to (e.g., original Rails.logger)
|
|
61
|
+
# @return [BrainzLab::Recall::Logger]
|
|
62
|
+
def logger(broadcast_to: nil)
|
|
63
|
+
Recall::Logger.new(nil, broadcast_to: broadcast_to)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Debug logging helper
|
|
67
|
+
def debug_log(message)
|
|
68
|
+
configuration.debug_log(message)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Check if debug mode is enabled
|
|
72
|
+
def debug?
|
|
73
|
+
configuration.debug?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Health check - verifies connectivity to all enabled services
|
|
77
|
+
# @return [Hash] Status of each service
|
|
78
|
+
def health_check
|
|
79
|
+
results = { status: 'ok', services: {} }
|
|
80
|
+
|
|
81
|
+
# Check Recall
|
|
82
|
+
if configuration.recall_enabled
|
|
83
|
+
results[:services][:recall] = check_service_health(
|
|
84
|
+
url: configuration.recall_url,
|
|
85
|
+
name: 'Recall'
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Check Reflex
|
|
90
|
+
if configuration.reflex_enabled
|
|
91
|
+
results[:services][:reflex] = check_service_health(
|
|
92
|
+
url: configuration.reflex_url,
|
|
93
|
+
name: 'Reflex'
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Check Pulse
|
|
98
|
+
if configuration.pulse_enabled
|
|
99
|
+
results[:services][:pulse] = check_service_health(
|
|
100
|
+
url: configuration.pulse_url,
|
|
101
|
+
name: 'Pulse'
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Overall status
|
|
106
|
+
has_failure = results[:services].values.any? { |s| s[:status] == 'error' }
|
|
107
|
+
results[:status] = has_failure ? 'degraded' : 'ok'
|
|
108
|
+
|
|
109
|
+
results
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def check_service_health(url:, name:)
|
|
115
|
+
require 'net/http'
|
|
116
|
+
require 'uri'
|
|
117
|
+
|
|
118
|
+
uri = URI.parse("#{url}/up")
|
|
119
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
120
|
+
http.use_ssl = uri.scheme == 'https'
|
|
121
|
+
http.open_timeout = 5
|
|
122
|
+
http.read_timeout = 5
|
|
123
|
+
|
|
124
|
+
response = http.get(uri.request_uri)
|
|
125
|
+
|
|
126
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
127
|
+
{ status: 'ok', latency_ms: 0 }
|
|
128
|
+
else
|
|
129
|
+
{ status: 'error', message: "HTTP #{response.code}" }
|
|
130
|
+
end
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
{ status: 'error', message: e.message }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Auto-load Rails integration if Rails is available
|
|
138
|
+
if defined?(Rails::Railtie)
|
|
139
|
+
require_relative "brainzlab/rails/railtie"
|
|
140
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Brainzlab
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
desc "Creates a BrainzLab initializer for your Rails application"
|
|
11
|
+
|
|
12
|
+
class_option :key, type: :string, desc: "Your BrainzLab secret key"
|
|
13
|
+
class_option :replace_logger, type: :boolean, default: false, desc: "Replace Rails.logger with BrainzLab logger"
|
|
14
|
+
|
|
15
|
+
def copy_initializer
|
|
16
|
+
template "brainzlab.rb.tt", "config/initializers/brainzlab.rb"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def show_post_install_message
|
|
20
|
+
say ""
|
|
21
|
+
say "BrainzLab SDK installed successfully!", :green
|
|
22
|
+
say ""
|
|
23
|
+
say "Next steps:"
|
|
24
|
+
say " 1. Set your environment variables:"
|
|
25
|
+
say " BRAINZLAB_SECRET_KEY - Your API key from https://brainzlab.ai/dashboard"
|
|
26
|
+
say ""
|
|
27
|
+
say " Or for auto-provisioning:"
|
|
28
|
+
say " RECALL_MASTER_KEY - Master key for Recall auto-provisioning"
|
|
29
|
+
say " REFLEX_MASTER_KEY - Master key for Reflex auto-provisioning"
|
|
30
|
+
say ""
|
|
31
|
+
say " 2. Start logging:"
|
|
32
|
+
say " BrainzLab::Recall.info('Hello from BrainzLab!')"
|
|
33
|
+
say ""
|
|
34
|
+
say " 3. Capture errors (automatic with Rails, or manual):"
|
|
35
|
+
say " BrainzLab::Reflex.capture(exception)"
|
|
36
|
+
say ""
|
|
37
|
+
if options[:replace_logger]
|
|
38
|
+
say " Rails.logger is now connected to Recall!", :yellow
|
|
39
|
+
else
|
|
40
|
+
say " To send all Rails logs to Recall, add to your initializer:"
|
|
41
|
+
say " Rails.logger = BrainzLab.logger(broadcast_to: Rails.logger)"
|
|
42
|
+
end
|
|
43
|
+
say ""
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def secret_key_value
|
|
49
|
+
if options[:key].present?
|
|
50
|
+
%("#{options[:key]}")
|
|
51
|
+
else
|
|
52
|
+
'ENV["BRAINZLAB_SECRET_KEY"]'
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def app_name
|
|
57
|
+
Rails.application.class.module_parent_name.underscore rescue "my-app"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|