fluyenta-ruby 0.1.14

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