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,590 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BrainzLab
|
|
4
|
+
module Rails
|
|
5
|
+
class Railtie < ::Rails::Railtie
|
|
6
|
+
generators do
|
|
7
|
+
require "generators/brainzlab/install/install_generator"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
initializer "brainzlab.configure_rails_initialization" do |app|
|
|
11
|
+
# Set defaults from Rails
|
|
12
|
+
BrainzLab.configure do |config|
|
|
13
|
+
config.environment ||= ::Rails.env.to_s
|
|
14
|
+
config.service ||= begin
|
|
15
|
+
::Rails.application.class.module_parent_name.underscore
|
|
16
|
+
rescue StandardError
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Add request context middleware (runs early)
|
|
22
|
+
app.middleware.insert_after ActionDispatch::RequestId, BrainzLab::Rails::Middleware
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
config.after_initialize do
|
|
26
|
+
# Set up custom log formatter
|
|
27
|
+
setup_log_formatter if BrainzLab.configuration.log_formatter_enabled
|
|
28
|
+
|
|
29
|
+
# Install instrumentation (HTTP tracking, etc.)
|
|
30
|
+
BrainzLab::Instrumentation.install!
|
|
31
|
+
|
|
32
|
+
# Install Pulse APM instrumentation (DB, views, cache)
|
|
33
|
+
BrainzLab::Pulse::Instrumentation.install!
|
|
34
|
+
|
|
35
|
+
# Hook into Rails 7+ error reporting
|
|
36
|
+
if defined?(::Rails.error) && ::Rails.error.respond_to?(:subscribe)
|
|
37
|
+
::Rails.error.subscribe(BrainzLab::Rails::ErrorSubscriber.new)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Hook into ActiveJob
|
|
41
|
+
if defined?(ActiveJob::Base)
|
|
42
|
+
ActiveJob::Base.include(BrainzLab::Rails::ActiveJobExtension)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Hook into ActionController for rescue_from fallback
|
|
46
|
+
if defined?(ActionController::Base)
|
|
47
|
+
ActionController::Base.include(BrainzLab::Rails::ControllerExtension)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Hook into Sidekiq if available
|
|
51
|
+
if defined?(Sidekiq)
|
|
52
|
+
Sidekiq.configure_server do |config|
|
|
53
|
+
config.error_handlers << BrainzLab::Rails::SidekiqErrorHandler.new
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
def setup_log_formatter
|
|
60
|
+
# Lazy require to ensure Rails is fully loaded
|
|
61
|
+
require_relative "log_formatter"
|
|
62
|
+
require_relative "log_subscriber"
|
|
63
|
+
|
|
64
|
+
config = BrainzLab.configuration
|
|
65
|
+
|
|
66
|
+
formatter_config = {
|
|
67
|
+
enabled: config.log_formatter_enabled,
|
|
68
|
+
colors: config.log_formatter_colors.nil? ? $stdout.tty? : config.log_formatter_colors,
|
|
69
|
+
hide_assets: config.log_formatter_hide_assets,
|
|
70
|
+
compact_assets: config.log_formatter_compact_assets,
|
|
71
|
+
show_params: config.log_formatter_show_params
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Create formatter and attach to subscriber
|
|
75
|
+
formatter = LogFormatter.new(formatter_config)
|
|
76
|
+
LogSubscriber.formatter = formatter
|
|
77
|
+
|
|
78
|
+
# Attach our subscribers
|
|
79
|
+
LogSubscriber.attach_to :action_controller
|
|
80
|
+
SqlLogSubscriber.attach_to :active_record
|
|
81
|
+
ViewLogSubscriber.attach_to :action_view
|
|
82
|
+
CableLogSubscriber.attach_to :action_cable
|
|
83
|
+
|
|
84
|
+
# Silence Rails default ActionController logging
|
|
85
|
+
silence_rails_logging
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def silence_rails_logging
|
|
89
|
+
# Create a null logger that discards all output
|
|
90
|
+
null_logger = Logger.new(File::NULL)
|
|
91
|
+
null_logger.level = Logger::FATAL
|
|
92
|
+
|
|
93
|
+
# Silence ActiveRecord SQL logging
|
|
94
|
+
if defined?(ActiveRecord::Base)
|
|
95
|
+
ActiveRecord::Base.logger = null_logger
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Silence ActionController logging (the "Completed" message)
|
|
99
|
+
if defined?(ActionController::Base)
|
|
100
|
+
ActionController::Base.logger = null_logger
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Silence ActionView logging
|
|
104
|
+
if defined?(ActionView::Base)
|
|
105
|
+
ActionView::Base.logger = null_logger
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Silence the class-level loggers for specific subscribers
|
|
109
|
+
if defined?(ActionController::LogSubscriber)
|
|
110
|
+
ActionController::LogSubscriber.logger = null_logger
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
if defined?(ActionView::LogSubscriber)
|
|
114
|
+
ActionView::LogSubscriber.logger = null_logger
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
if defined?(ActiveRecord::LogSubscriber)
|
|
118
|
+
ActiveRecord::LogSubscriber.logger = null_logger
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Silence ActionCable logging
|
|
122
|
+
if defined?(ActionCable::Server::Base)
|
|
123
|
+
ActionCable.server.config.logger = null_logger
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if defined?(ActionCable::Connection::TaggedLoggerProxy)
|
|
127
|
+
# ActionCable uses a tagged logger proxy that we need to quiet
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Silence the main Rails logger to remove "Started GET" messages
|
|
131
|
+
# Wrap the formatter to filter specific messages
|
|
132
|
+
if defined?(::Rails.logger) && ::Rails.logger.respond_to?(:formatter=)
|
|
133
|
+
original_formatter = ::Rails.logger.formatter || Logger::Formatter.new
|
|
134
|
+
::Rails.logger.formatter = FilteringFormatter.new(original_formatter)
|
|
135
|
+
end
|
|
136
|
+
rescue StandardError
|
|
137
|
+
# Silently fail if we can't silence
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Filtering formatter that suppresses request-related messages
|
|
143
|
+
# Uses SimpleDelegator to support all formatter methods (including tagged logging)
|
|
144
|
+
class FilteringFormatter < SimpleDelegator
|
|
145
|
+
FILTERED_PATTERNS = [
|
|
146
|
+
/^Started (GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)/,
|
|
147
|
+
/^Processing by/,
|
|
148
|
+
/^Completed \d+/,
|
|
149
|
+
/^Cannot render console from/,
|
|
150
|
+
/^Parameters:/,
|
|
151
|
+
/^Rendering/,
|
|
152
|
+
/^Rendered/,
|
|
153
|
+
/^\[ActionCable\] Broadcasting/,
|
|
154
|
+
/^\s*$/ # Empty lines
|
|
155
|
+
].freeze
|
|
156
|
+
|
|
157
|
+
def call(severity, datetime, progname, msg)
|
|
158
|
+
return nil if should_filter?(msg)
|
|
159
|
+
|
|
160
|
+
__getobj__.call(severity, datetime, progname, msg)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def should_filter?(msg)
|
|
166
|
+
return false unless msg
|
|
167
|
+
|
|
168
|
+
msg_str = msg.to_s
|
|
169
|
+
FILTERED_PATTERNS.any? { |pattern| msg_str =~ pattern }
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Middleware for request context
|
|
174
|
+
class Middleware
|
|
175
|
+
def initialize(app)
|
|
176
|
+
@app = app
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def call(env)
|
|
180
|
+
request = ActionDispatch::Request.new(env)
|
|
181
|
+
started_at = Time.now.utc
|
|
182
|
+
|
|
183
|
+
# Set request context
|
|
184
|
+
context = BrainzLab::Context.current
|
|
185
|
+
request_id = request.request_id || env["action_dispatch.request_id"]
|
|
186
|
+
context.request_id = request_id
|
|
187
|
+
|
|
188
|
+
# Store request_id in thread local for log subscriber
|
|
189
|
+
Thread.current[:brainzlab_request_id] = request_id
|
|
190
|
+
|
|
191
|
+
# Capture session_id - access session to ensure it's loaded
|
|
192
|
+
if request.session.respond_to?(:id)
|
|
193
|
+
# Force session load by accessing it
|
|
194
|
+
session_id = request.session.id rescue nil
|
|
195
|
+
context.session_id = session_id.to_s if session_id.present?
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Capture full request info for Reflex
|
|
199
|
+
context.request_method = request.request_method
|
|
200
|
+
context.request_path = request.path
|
|
201
|
+
context.request_url = request.url
|
|
202
|
+
context.request_params = filter_params(request.params.to_h)
|
|
203
|
+
context.request_headers = extract_headers(env)
|
|
204
|
+
|
|
205
|
+
# Add breadcrumb for request start
|
|
206
|
+
BrainzLab::Reflex.add_breadcrumb(
|
|
207
|
+
"#{request.request_method} #{request.path}",
|
|
208
|
+
category: "http.request",
|
|
209
|
+
level: :info,
|
|
210
|
+
data: { url: request.url }
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Add request data to Recall context
|
|
214
|
+
context.set_context(
|
|
215
|
+
path: request.path,
|
|
216
|
+
method: request.request_method,
|
|
217
|
+
ip: request.remote_ip,
|
|
218
|
+
user_agent: request.user_agent
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Extract distributed tracing context from incoming request headers
|
|
222
|
+
parent_context = BrainzLab::Pulse.extract!(env)
|
|
223
|
+
|
|
224
|
+
# Start Pulse trace if enabled and path not excluded
|
|
225
|
+
should_trace = should_trace_request?(request)
|
|
226
|
+
if should_trace
|
|
227
|
+
# Initialize spans array for this request
|
|
228
|
+
Thread.current[:brainzlab_pulse_spans] = []
|
|
229
|
+
Thread.current[:brainzlab_pulse_breakdown] = nil
|
|
230
|
+
BrainzLab::Pulse.start_trace(
|
|
231
|
+
"#{request.request_method} #{request.path}",
|
|
232
|
+
kind: "request",
|
|
233
|
+
parent_context: parent_context
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
status, headers, response = @app.call(env)
|
|
238
|
+
|
|
239
|
+
# Add breadcrumb for response
|
|
240
|
+
BrainzLab::Reflex.add_breadcrumb(
|
|
241
|
+
"Response #{status}",
|
|
242
|
+
category: "http.response",
|
|
243
|
+
level: status >= 400 ? :error : :info,
|
|
244
|
+
data: { status: status }
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
[status, headers, response]
|
|
248
|
+
rescue StandardError => e
|
|
249
|
+
# Record error in Pulse trace
|
|
250
|
+
if should_trace
|
|
251
|
+
BrainzLab::Pulse.finish_trace(
|
|
252
|
+
error: true,
|
|
253
|
+
error_class: e.class.name,
|
|
254
|
+
error_message: e.message
|
|
255
|
+
)
|
|
256
|
+
end
|
|
257
|
+
raise
|
|
258
|
+
ensure
|
|
259
|
+
# Finish Pulse trace for successful requests
|
|
260
|
+
if should_trace && !$!
|
|
261
|
+
record_pulse_trace(request, started_at, status)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
Thread.current[:brainzlab_request_id] = nil
|
|
265
|
+
BrainzLab::Context.clear!
|
|
266
|
+
BrainzLab::Pulse::Propagation.clear!
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def should_trace_request?(request)
|
|
270
|
+
return false unless BrainzLab.configuration.pulse_enabled
|
|
271
|
+
|
|
272
|
+
excluded = BrainzLab.configuration.pulse_excluded_paths || []
|
|
273
|
+
path = request.path
|
|
274
|
+
|
|
275
|
+
# Check if path matches any excluded pattern
|
|
276
|
+
!excluded.any? do |pattern|
|
|
277
|
+
if pattern.include?("*")
|
|
278
|
+
File.fnmatch?(pattern, path)
|
|
279
|
+
else
|
|
280
|
+
path.start_with?(pattern)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def record_pulse_trace(request, started_at, status)
|
|
286
|
+
ended_at = Time.now.utc
|
|
287
|
+
context = BrainzLab::Context.current
|
|
288
|
+
|
|
289
|
+
# Collect spans from instrumentation
|
|
290
|
+
spans = Thread.current[:brainzlab_pulse_spans] || []
|
|
291
|
+
breakdown = Thread.current[:brainzlab_pulse_breakdown] || {}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# Format spans for API
|
|
295
|
+
formatted_spans = spans.map do |span|
|
|
296
|
+
{
|
|
297
|
+
span_id: span[:span_id],
|
|
298
|
+
name: span[:name],
|
|
299
|
+
kind: span[:kind],
|
|
300
|
+
started_at: format_timestamp(span[:started_at]),
|
|
301
|
+
ended_at: format_timestamp(span[:ended_at]),
|
|
302
|
+
duration_ms: span[:duration_ms],
|
|
303
|
+
data: span[:data]
|
|
304
|
+
}
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
BrainzLab::Pulse.record_trace(
|
|
308
|
+
"#{request.request_method} #{request.path}",
|
|
309
|
+
kind: "request",
|
|
310
|
+
started_at: started_at,
|
|
311
|
+
ended_at: ended_at,
|
|
312
|
+
request_id: context.request_id,
|
|
313
|
+
request_method: request.request_method,
|
|
314
|
+
request_path: request.path,
|
|
315
|
+
controller: context.controller,
|
|
316
|
+
action: context.action,
|
|
317
|
+
status: status,
|
|
318
|
+
error: status.to_i >= 500,
|
|
319
|
+
view_ms: breakdown[:view_ms],
|
|
320
|
+
db_ms: breakdown[:db_ms],
|
|
321
|
+
spans: formatted_spans
|
|
322
|
+
)
|
|
323
|
+
rescue StandardError => e
|
|
324
|
+
BrainzLab.configuration.logger&.error("[BrainzLab::Pulse] Failed to record trace: #{e.message}")
|
|
325
|
+
ensure
|
|
326
|
+
# Clean up thread locals
|
|
327
|
+
Thread.current[:brainzlab_pulse_spans] = nil
|
|
328
|
+
Thread.current[:brainzlab_pulse_breakdown] = nil
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
private
|
|
332
|
+
|
|
333
|
+
def filter_params(params)
|
|
334
|
+
filtered = params.dup
|
|
335
|
+
BrainzLab::Reflex::FILTERED_PARAMS.each do |key|
|
|
336
|
+
filtered.delete(key)
|
|
337
|
+
filtered.delete(key.to_sym)
|
|
338
|
+
end
|
|
339
|
+
# Also filter nested password fields
|
|
340
|
+
deep_filter(filtered)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def deep_filter(obj)
|
|
344
|
+
case obj
|
|
345
|
+
when Hash
|
|
346
|
+
obj.each_with_object({}) do |(k, v), h|
|
|
347
|
+
if BrainzLab::Reflex::FILTERED_PARAMS.include?(k.to_s)
|
|
348
|
+
h[k] = "[FILTERED]"
|
|
349
|
+
else
|
|
350
|
+
h[k] = deep_filter(v)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
when Array
|
|
354
|
+
obj.map { |v| deep_filter(v) }
|
|
355
|
+
else
|
|
356
|
+
obj
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def format_timestamp(ts)
|
|
361
|
+
return nil unless ts
|
|
362
|
+
|
|
363
|
+
case ts
|
|
364
|
+
when Time, DateTime
|
|
365
|
+
ts.utc.iso8601(3)
|
|
366
|
+
when Float, Integer
|
|
367
|
+
Time.at(ts).utc.iso8601(3)
|
|
368
|
+
when String
|
|
369
|
+
ts
|
|
370
|
+
else
|
|
371
|
+
ts.to_s
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def extract_headers(env)
|
|
376
|
+
headers = {}
|
|
377
|
+
env.each do |key, value|
|
|
378
|
+
next unless key.start_with?("HTTP_")
|
|
379
|
+
next if key == "HTTP_COOKIE"
|
|
380
|
+
next if key == "HTTP_AUTHORIZATION"
|
|
381
|
+
|
|
382
|
+
header_name = key.sub("HTTP_", "").split("_").map(&:capitalize).join("-")
|
|
383
|
+
headers[header_name] = value
|
|
384
|
+
end
|
|
385
|
+
headers
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Rails 7+ ErrorReporter subscriber
|
|
390
|
+
class ErrorSubscriber
|
|
391
|
+
def report(error, handled:, severity:, context: {}, source: nil)
|
|
392
|
+
# Capture both handled and unhandled, but mark them
|
|
393
|
+
BrainzLab::Reflex.capture(error,
|
|
394
|
+
handled: handled,
|
|
395
|
+
severity: severity.to_s,
|
|
396
|
+
source: source,
|
|
397
|
+
extra: context
|
|
398
|
+
)
|
|
399
|
+
rescue StandardError => e
|
|
400
|
+
BrainzLab.configuration.logger&.error("[BrainzLab] ErrorSubscriber failed: #{e.message}")
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# ActionController extension for error capture
|
|
405
|
+
module ControllerExtension
|
|
406
|
+
extend ActiveSupport::Concern
|
|
407
|
+
|
|
408
|
+
included do
|
|
409
|
+
around_action :brainzlab_capture_context
|
|
410
|
+
rescue_from Exception, with: :brainzlab_capture_exception
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
private
|
|
414
|
+
|
|
415
|
+
def brainzlab_capture_context
|
|
416
|
+
# Set controller/action context
|
|
417
|
+
context = BrainzLab::Context.current
|
|
418
|
+
context.controller = self.class.name
|
|
419
|
+
context.action = action_name
|
|
420
|
+
|
|
421
|
+
# Add breadcrumb
|
|
422
|
+
BrainzLab::Reflex.add_breadcrumb(
|
|
423
|
+
"#{self.class.name}##{action_name}",
|
|
424
|
+
category: "controller",
|
|
425
|
+
level: :info
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
yield
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def brainzlab_capture_exception(exception)
|
|
432
|
+
BrainzLab::Reflex.capture(exception)
|
|
433
|
+
raise exception # Re-raise to let Rails handle it
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# ActiveJob extension for background job error capture and Pulse tracing
|
|
438
|
+
module ActiveJobExtension
|
|
439
|
+
extend ActiveSupport::Concern
|
|
440
|
+
|
|
441
|
+
included do
|
|
442
|
+
around_perform :brainzlab_around_perform
|
|
443
|
+
rescue_from Exception, with: :brainzlab_rescue_job
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
private
|
|
447
|
+
|
|
448
|
+
def brainzlab_around_perform
|
|
449
|
+
started_at = Time.now.utc
|
|
450
|
+
|
|
451
|
+
# Set context for Reflex and Recall
|
|
452
|
+
BrainzLab::Context.current.set_context(
|
|
453
|
+
job_class: self.class.name,
|
|
454
|
+
job_id: job_id,
|
|
455
|
+
queue_name: queue_name,
|
|
456
|
+
arguments: arguments.map(&:to_s).first(5) # Limit for safety
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
BrainzLab::Reflex.add_breadcrumb(
|
|
460
|
+
"Job #{self.class.name}",
|
|
461
|
+
category: "job",
|
|
462
|
+
level: :info,
|
|
463
|
+
data: { job_id: job_id, queue: queue_name }
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
# Start Pulse trace for job if enabled
|
|
467
|
+
should_trace = BrainzLab.configuration.pulse_enabled
|
|
468
|
+
if should_trace
|
|
469
|
+
Thread.current[:brainzlab_pulse_spans] = []
|
|
470
|
+
Thread.current[:brainzlab_pulse_breakdown] = nil
|
|
471
|
+
BrainzLab::Pulse.start_trace(self.class.name, kind: "job")
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
error_occurred = nil
|
|
475
|
+
begin
|
|
476
|
+
yield
|
|
477
|
+
rescue StandardError => e
|
|
478
|
+
error_occurred = e
|
|
479
|
+
raise
|
|
480
|
+
end
|
|
481
|
+
ensure
|
|
482
|
+
# Record Pulse trace for job
|
|
483
|
+
if should_trace
|
|
484
|
+
record_pulse_job_trace(started_at, error_occurred)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
BrainzLab::Context.clear!
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def record_pulse_job_trace(started_at, error = nil)
|
|
491
|
+
ended_at = Time.now.utc
|
|
492
|
+
|
|
493
|
+
# Collect spans from instrumentation
|
|
494
|
+
spans = Thread.current[:brainzlab_pulse_spans] || []
|
|
495
|
+
breakdown = Thread.current[:brainzlab_pulse_breakdown] || {}
|
|
496
|
+
|
|
497
|
+
# Format spans for API
|
|
498
|
+
formatted_spans = spans.map do |span|
|
|
499
|
+
{
|
|
500
|
+
span_id: span[:span_id],
|
|
501
|
+
name: span[:name],
|
|
502
|
+
kind: span[:kind],
|
|
503
|
+
started_at: format_job_timestamp(span[:started_at]),
|
|
504
|
+
ended_at: format_job_timestamp(span[:ended_at]),
|
|
505
|
+
duration_ms: span[:duration_ms],
|
|
506
|
+
data: span[:data]
|
|
507
|
+
}
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Calculate queue wait time if available
|
|
511
|
+
queue_wait_ms = nil
|
|
512
|
+
if respond_to?(:scheduled_at) && scheduled_at
|
|
513
|
+
queue_wait_ms = ((started_at - scheduled_at) * 1000).round(2)
|
|
514
|
+
elsif respond_to?(:enqueued_at) && enqueued_at
|
|
515
|
+
queue_wait_ms = ((started_at - enqueued_at) * 1000).round(2)
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
BrainzLab::Pulse.record_trace(
|
|
519
|
+
self.class.name,
|
|
520
|
+
kind: "job",
|
|
521
|
+
started_at: started_at,
|
|
522
|
+
ended_at: ended_at,
|
|
523
|
+
job_class: self.class.name,
|
|
524
|
+
job_id: job_id,
|
|
525
|
+
queue: queue_name,
|
|
526
|
+
error: error.present?,
|
|
527
|
+
error_class: error&.class&.name,
|
|
528
|
+
error_message: error&.message,
|
|
529
|
+
db_ms: breakdown[:db_ms],
|
|
530
|
+
queue_wait_ms: queue_wait_ms,
|
|
531
|
+
executions: executions,
|
|
532
|
+
spans: formatted_spans
|
|
533
|
+
)
|
|
534
|
+
rescue StandardError => e
|
|
535
|
+
BrainzLab.configuration.logger&.error("[BrainzLab::Pulse] Failed to record job trace: #{e.message}")
|
|
536
|
+
ensure
|
|
537
|
+
# Clean up thread locals
|
|
538
|
+
Thread.current[:brainzlab_pulse_spans] = nil
|
|
539
|
+
Thread.current[:brainzlab_pulse_breakdown] = nil
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def format_job_timestamp(ts)
|
|
543
|
+
return nil unless ts
|
|
544
|
+
|
|
545
|
+
case ts
|
|
546
|
+
when Time, DateTime
|
|
547
|
+
ts.utc.iso8601(3)
|
|
548
|
+
when Float, Integer
|
|
549
|
+
Time.at(ts).utc.iso8601(3)
|
|
550
|
+
when String
|
|
551
|
+
ts
|
|
552
|
+
else
|
|
553
|
+
ts.to_s
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def brainzlab_rescue_job(exception)
|
|
558
|
+
BrainzLab::Reflex.capture(exception,
|
|
559
|
+
tags: { type: "background_job" },
|
|
560
|
+
extra: {
|
|
561
|
+
job_class: self.class.name,
|
|
562
|
+
job_id: job_id,
|
|
563
|
+
queue_name: queue_name,
|
|
564
|
+
executions: executions,
|
|
565
|
+
arguments: arguments.map(&:to_s).first(5)
|
|
566
|
+
}
|
|
567
|
+
)
|
|
568
|
+
raise exception # Re-raise to let ActiveJob handle retries
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# Sidekiq error handler
|
|
573
|
+
class SidekiqErrorHandler
|
|
574
|
+
def call(exception, context)
|
|
575
|
+
BrainzLab::Reflex.capture(exception,
|
|
576
|
+
tags: { type: "sidekiq" },
|
|
577
|
+
extra: {
|
|
578
|
+
job_class: context[:job]["class"],
|
|
579
|
+
job_id: context[:job]["jid"],
|
|
580
|
+
queue: context[:job]["queue"],
|
|
581
|
+
args: context[:job]["args"]&.map(&:to_s)&.first(5),
|
|
582
|
+
retry_count: context[:job]["retry_count"]
|
|
583
|
+
}
|
|
584
|
+
)
|
|
585
|
+
rescue StandardError => e
|
|
586
|
+
BrainzLab.configuration.logger&.error("[BrainzLab] Sidekiq handler failed: #{e.message}")
|
|
587
|
+
end
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module BrainzLab
|
|
6
|
+
module Recall
|
|
7
|
+
class Buffer
|
|
8
|
+
def initialize(config, client)
|
|
9
|
+
@config = config
|
|
10
|
+
@client = client
|
|
11
|
+
@buffer = Concurrent::Array.new
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
@flush_thread = nil
|
|
14
|
+
@shutdown = false
|
|
15
|
+
|
|
16
|
+
start_flush_thread
|
|
17
|
+
setup_at_exit
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def push(log_entry)
|
|
21
|
+
@buffer.push(log_entry)
|
|
22
|
+
flush if @buffer.size >= @config.recall_buffer_size
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def flush
|
|
26
|
+
return if @buffer.empty?
|
|
27
|
+
|
|
28
|
+
entries = nil
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
entries = @buffer.dup
|
|
31
|
+
@buffer.clear
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
return if entries.nil? || entries.empty?
|
|
35
|
+
|
|
36
|
+
@client.send_batch(entries)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def shutdown
|
|
40
|
+
@shutdown = true
|
|
41
|
+
@flush_thread&.kill
|
|
42
|
+
flush
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def start_flush_thread
|
|
48
|
+
@flush_thread = Thread.new do
|
|
49
|
+
loop do
|
|
50
|
+
break if @shutdown
|
|
51
|
+
|
|
52
|
+
sleep(@config.recall_flush_interval)
|
|
53
|
+
flush unless @shutdown
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
@flush_thread.abort_on_exception = false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def setup_at_exit
|
|
60
|
+
at_exit { shutdown }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|