brainzlab 0.1.3 → 0.1.4

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.
@@ -6,20 +6,27 @@ module BrainzLab
6
6
  SCHEMA_QUERIES = %w[SCHEMA EXPLAIN].freeze
7
7
  INTERNAL_TABLES = %w[pg_ information_schema sqlite_ mysql.].freeze
8
8
 
9
+ # Thresholds for slow query detection (in milliseconds)
10
+ SLOW_QUERY_THRESHOLD = 100
11
+ VERY_SLOW_QUERY_THRESHOLD = 1000
12
+
13
+ # N+1 detection settings
14
+ N_PLUS_ONE_THRESHOLD = 5 # queries to same table in single request
15
+ N_PLUS_ONE_WINDOW = 50 # max queries to track per request
16
+
9
17
  class << self
10
18
  def install!
11
19
  return unless defined?(::ActiveRecord)
12
20
  return if @installed
13
21
 
14
- ActiveSupport::Notifications.subscribe('sql.active_record') do |*args|
15
- event = ActiveSupport::Notifications::Event.new(*args)
16
- next if skip_query?(event.payload)
17
-
18
- record_breadcrumb(event)
19
- end
22
+ install_sql_subscriber!
23
+ install_instantiation_subscriber!
24
+ install_transaction_subscribers!
25
+ install_strict_loading_subscriber!
26
+ install_deprecated_association_subscriber!
20
27
 
21
28
  @installed = true
22
- BrainzLab.debug_log('ActiveRecord breadcrumbs installed')
29
+ BrainzLab.debug_log('ActiveRecord instrumentation installed (sql, instantiation, transactions, strict_loading)')
23
30
  end
24
31
 
25
32
  def installed?
@@ -28,16 +35,43 @@ module BrainzLab
28
35
 
29
36
  private
30
37
 
31
- def record_breadcrumb(event)
38
+ # ============================================
39
+ # SQL Query Instrumentation
40
+ # ============================================
41
+ def install_sql_subscriber!
42
+ ActiveSupport::Notifications.subscribe('sql.active_record') do |*args|
43
+ event = ActiveSupport::Notifications::Event.new(*args)
44
+ next if skip_query?(event.payload)
45
+
46
+ handle_sql_event(event)
47
+ end
48
+ end
49
+
50
+ def handle_sql_event(event)
32
51
  payload = event.payload
33
- sql = payload[:sql]
34
- name = payload[:name] || 'SQL'
35
52
  duration = event.duration.round(2)
36
53
 
37
- # Extract operation type from SQL
54
+ # Record breadcrumb for Reflex (enhanced)
55
+ record_sql_breadcrumb(event, duration)
56
+
57
+ # Add span to Pulse (APM)
58
+ record_sql_span(event, duration)
59
+
60
+ # Log slow queries to Recall
61
+ log_slow_query(event, duration) if duration >= SLOW_QUERY_THRESHOLD
62
+
63
+ # Track for N+1 detection
64
+ track_query_for_n_plus_one(event)
65
+ rescue StandardError => e
66
+ BrainzLab.debug_log("ActiveRecord SQL instrumentation failed: #{e.message}")
67
+ end
68
+
69
+ def record_sql_breadcrumb(event, duration)
70
+ payload = event.payload
71
+ sql = payload[:sql]
72
+ name = payload[:name] || 'SQL'
38
73
  operation = extract_operation(sql)
39
74
 
40
- # Build breadcrumb message
41
75
  message = if payload[:cached]
42
76
  "#{name} (cached)"
43
77
  else
@@ -45,12 +79,10 @@ module BrainzLab
45
79
  end
46
80
 
47
81
  # Determine level based on duration
48
- level = if duration > 100
49
- :warning
50
- elsif duration > 1000
51
- :error
52
- else
53
- :info
82
+ level = case duration
83
+ when 0...SLOW_QUERY_THRESHOLD then :info
84
+ when SLOW_QUERY_THRESHOLD...VERY_SLOW_QUERY_THRESHOLD then :warning
85
+ else :error
54
86
  end
55
87
 
56
88
  BrainzLab::Reflex.add_breadcrumb(
@@ -61,26 +93,404 @@ module BrainzLab
61
93
  sql: truncate_sql(sql),
62
94
  duration_ms: duration,
63
95
  cached: payload[:cached] || false,
96
+ async: payload[:async] || false,
97
+ row_count: payload[:row_count],
98
+ affected_rows: payload[:affected_rows],
64
99
  connection_name: extract_connection_name(payload[:connection])
65
100
  }.compact
66
101
  )
102
+ end
103
+
104
+ def record_sql_span(event, duration)
105
+ # Only add spans if Pulse is enabled and there's an active trace
106
+ return unless BrainzLab.configuration.pulse_effectively_enabled?
107
+
108
+ tracer = BrainzLab::Pulse.tracer
109
+ return unless tracer.current_trace
110
+
111
+ payload = event.payload
112
+ sql = payload[:sql]
113
+ operation = extract_operation(sql)
114
+ name = payload[:name] || 'SQL'
115
+
116
+ # Build span data
117
+ span_data = {
118
+ span_id: SecureRandom.uuid,
119
+ name: "db.#{operation}",
120
+ kind: 'db',
121
+ started_at: event.time,
122
+ ended_at: event.end,
123
+ duration_ms: duration,
124
+ error: false,
125
+ data: {
126
+ 'db.system' => extract_adapter_name(payload[:connection]),
127
+ 'db.name' => extract_database_name(payload[:connection]),
128
+ 'db.statement' => truncate_sql(sql, 1000),
129
+ 'db.operation' => operation,
130
+ 'db.query_name' => name,
131
+ 'db.cached' => payload[:cached] || false,
132
+ 'db.async' => payload[:async] || false,
133
+ 'db.row_count' => payload[:row_count],
134
+ 'db.affected_rows' => payload[:affected_rows]
135
+ }.compact
136
+ }
137
+
138
+ tracer.current_spans << span_data
139
+ end
140
+
141
+ def log_slow_query(event, duration)
142
+ return unless BrainzLab.configuration.recall_effectively_enabled?
143
+
144
+ payload = event.payload
145
+ sql = payload[:sql]
146
+ operation = extract_operation(sql)
147
+ name = payload[:name] || 'SQL'
148
+
149
+ level = duration >= VERY_SLOW_QUERY_THRESHOLD ? :error : :warn
150
+
151
+ BrainzLab::Recall.send(
152
+ level,
153
+ "Slow SQL query: #{name} (#{duration}ms)",
154
+ sql: truncate_sql(sql, 2000),
155
+ duration_ms: duration,
156
+ operation: operation,
157
+ cached: payload[:cached] || false,
158
+ row_count: payload[:row_count],
159
+ affected_rows: payload[:affected_rows],
160
+ connection_name: extract_connection_name(payload[:connection]),
161
+ threshold_exceeded: duration >= VERY_SLOW_QUERY_THRESHOLD ? 'critical' : 'warning'
162
+ )
163
+ end
164
+
165
+ # ============================================
166
+ # N+1 Query Detection
167
+ # ============================================
168
+ def track_query_for_n_plus_one(event)
169
+ return unless BrainzLab.configuration.reflex_effectively_enabled?
170
+
171
+ payload = event.payload
172
+ sql = payload[:sql].to_s
173
+
174
+ # Extract table name from SELECT queries
175
+ table = extract_table_from_sql(sql)
176
+ return unless table
177
+
178
+ # Track queries per table in current request
179
+ query_tracker = Thread.current[:brainzlab_query_tracker] ||= {}
180
+ query_tracker[table] ||= { count: 0, queries: [] }
181
+
182
+ tracker = query_tracker[table]
183
+ tracker[:count] += 1
184
+
185
+ # Store sample queries (limited)
186
+ if tracker[:queries].size < 3
187
+ tracker[:queries] << truncate_sql(sql, 200)
188
+ end
189
+
190
+ # Detect N+1 pattern
191
+ if tracker[:count] == N_PLUS_ONE_THRESHOLD
192
+ report_n_plus_one(table, tracker)
193
+ end
194
+ end
195
+
196
+ def report_n_plus_one(table, tracker)
197
+ BrainzLab::Reflex.add_breadcrumb(
198
+ "Potential N+1 detected: #{tracker[:count]}+ queries to '#{table}'",
199
+ category: 'db.n_plus_one',
200
+ level: :warning,
201
+ data: {
202
+ table: table,
203
+ query_count: tracker[:count],
204
+ sample_queries: tracker[:queries]
205
+ }
206
+ )
207
+
208
+ if BrainzLab.configuration.recall_effectively_enabled?
209
+ BrainzLab::Recall.warn(
210
+ "Potential N+1 query detected",
211
+ table: table,
212
+ query_count: tracker[:count],
213
+ sample_queries: tracker[:queries]
214
+ )
215
+ end
216
+ end
217
+
218
+ def clear_n_plus_one_tracker!
219
+ Thread.current[:brainzlab_query_tracker] = nil
220
+ end
221
+
222
+ # ============================================
223
+ # Record Instantiation (for N+1 metrics)
224
+ # ============================================
225
+ def install_instantiation_subscriber!
226
+ ActiveSupport::Notifications.subscribe('instantiation.active_record') do |*args|
227
+ event = ActiveSupport::Notifications::Event.new(*args)
228
+ handle_instantiation_event(event)
229
+ end
230
+ end
231
+
232
+ def handle_instantiation_event(event)
233
+ payload = event.payload
234
+ record_count = payload[:record_count]
235
+ class_name = payload[:class_name]
236
+ duration = event.duration.round(2)
237
+
238
+ # Track instantiation metrics for Pulse
239
+ if BrainzLab.configuration.pulse_effectively_enabled?
240
+ tracer = BrainzLab::Pulse.tracer
241
+ if tracer.current_trace
242
+ span_data = {
243
+ span_id: SecureRandom.uuid,
244
+ name: "db.instantiate.#{class_name}",
245
+ kind: 'db',
246
+ started_at: event.time,
247
+ ended_at: event.end,
248
+ duration_ms: duration,
249
+ error: false,
250
+ data: {
251
+ 'db.operation' => 'instantiate',
252
+ 'db.model' => class_name,
253
+ 'db.record_count' => record_count
254
+ }
255
+ }
256
+
257
+ tracer.current_spans << span_data
258
+ end
259
+ end
260
+
261
+ # Add breadcrumb for large instantiations
262
+ if record_count >= 100
263
+ BrainzLab::Reflex.add_breadcrumb(
264
+ "Instantiated #{record_count} #{class_name} records",
265
+ category: 'db.instantiate',
266
+ level: record_count >= 1000 ? :warning : :info,
267
+ data: {
268
+ class_name: class_name,
269
+ record_count: record_count,
270
+ duration_ms: duration
271
+ }
272
+ )
273
+ end
67
274
  rescue StandardError => e
68
- BrainzLab.debug_log("ActiveRecord breadcrumb failed: #{e.message}")
275
+ BrainzLab.debug_log("ActiveRecord instantiation instrumentation failed: #{e.message}")
69
276
  end
70
277
 
278
+ # ============================================
279
+ # Transaction Tracking
280
+ # ============================================
281
+ def install_transaction_subscribers!
282
+ # Track transaction start
283
+ ActiveSupport::Notifications.subscribe('start_transaction.active_record') do |*args|
284
+ event = ActiveSupport::Notifications::Event.new(*args)
285
+ handle_transaction_start(event)
286
+ end
287
+
288
+ # Track transaction completion
289
+ ActiveSupport::Notifications.subscribe('transaction.active_record') do |*args|
290
+ event = ActiveSupport::Notifications::Event.new(*args)
291
+ handle_transaction_complete(event)
292
+ end
293
+ end
294
+
295
+ def handle_transaction_start(event)
296
+ # Store transaction start time for duration calculation
297
+ transaction = event.payload[:transaction]
298
+ return unless transaction
299
+
300
+ Thread.current[:brainzlab_transaction_starts] ||= {}
301
+ Thread.current[:brainzlab_transaction_starts][transaction.object_id] = event.time
302
+ rescue StandardError => e
303
+ BrainzLab.debug_log("ActiveRecord transaction start instrumentation failed: #{e.message}")
304
+ end
305
+
306
+ def handle_transaction_complete(event)
307
+ payload = event.payload
308
+ transaction = payload[:transaction]
309
+ outcome = payload[:outcome] # :commit, :rollback, :restart, :incomplete
310
+
311
+ # Calculate duration from stored start time
312
+ starts = Thread.current[:brainzlab_transaction_starts] || {}
313
+ start_time = starts.delete(transaction&.object_id) || event.time
314
+ duration = ((event.end - start_time) * 1000).round(2)
315
+
316
+ connection_name = extract_connection_name(payload[:connection])
317
+
318
+ # Add breadcrumb
319
+ level = case outcome
320
+ when :commit then :info
321
+ when :rollback then :warning
322
+ when :restart, :incomplete then :error
323
+ else :info
324
+ end
325
+
326
+ BrainzLab::Reflex.add_breadcrumb(
327
+ "Transaction #{outcome} (#{duration}ms)",
328
+ category: 'db.transaction',
329
+ level: level,
330
+ data: {
331
+ outcome: outcome.to_s,
332
+ duration_ms: duration,
333
+ connection_name: connection_name
334
+ }.compact
335
+ )
336
+
337
+ # Add Pulse span
338
+ if BrainzLab.configuration.pulse_effectively_enabled?
339
+ tracer = BrainzLab::Pulse.tracer
340
+ if tracer.current_trace
341
+ span_data = {
342
+ span_id: SecureRandom.uuid,
343
+ name: 'db.transaction',
344
+ kind: 'db',
345
+ started_at: start_time,
346
+ ended_at: event.end,
347
+ duration_ms: duration,
348
+ error: outcome != :commit,
349
+ data: {
350
+ 'db.operation' => 'transaction',
351
+ 'db.transaction.outcome' => outcome.to_s,
352
+ 'db.name' => connection_name
353
+ }.compact
354
+ }
355
+
356
+ tracer.current_spans << span_data
357
+ end
358
+ end
359
+
360
+ # Log rollbacks and errors to Recall
361
+ if outcome != :commit && BrainzLab.configuration.recall_effectively_enabled?
362
+ log_level = outcome == :rollback ? :warn : :error
363
+ BrainzLab::Recall.send(
364
+ log_level,
365
+ "Database transaction #{outcome}",
366
+ outcome: outcome.to_s,
367
+ duration_ms: duration,
368
+ connection_name: connection_name
369
+ )
370
+ end
371
+ rescue StandardError => e
372
+ BrainzLab.debug_log("ActiveRecord transaction instrumentation failed: #{e.message}")
373
+ end
374
+
375
+ # ============================================
376
+ # Strict Loading Violation Tracking
377
+ # ============================================
378
+ def install_strict_loading_subscriber!
379
+ ActiveSupport::Notifications.subscribe('strict_loading_violation.active_record') do |*args|
380
+ event = ActiveSupport::Notifications::Event.new(*args)
381
+ handle_strict_loading_violation(event)
382
+ end
383
+ end
384
+
385
+ def handle_strict_loading_violation(event)
386
+ payload = event.payload
387
+ owner = payload[:owner]
388
+ reflection = payload[:reflection]
389
+
390
+ owner_class = owner.is_a?(Class) ? owner.name : owner.class.name
391
+ association_name = reflection.respond_to?(:name) ? reflection.name : reflection.to_s
392
+
393
+ # Add breadcrumb
394
+ BrainzLab::Reflex.add_breadcrumb(
395
+ "Strict loading violation: #{owner_class}##{association_name}",
396
+ category: 'db.strict_loading',
397
+ level: :warning,
398
+ data: {
399
+ owner_class: owner_class,
400
+ association: association_name.to_s,
401
+ reflection_type: reflection.class.name
402
+ }
403
+ )
404
+
405
+ # Log to Recall
406
+ if BrainzLab.configuration.recall_effectively_enabled?
407
+ BrainzLab::Recall.warn(
408
+ "Strict loading violation detected",
409
+ owner_class: owner_class,
410
+ association: association_name.to_s,
411
+ message: "Attempted to lazily load #{association_name} on #{owner_class} with strict_loading enabled"
412
+ )
413
+ end
414
+ rescue StandardError => e
415
+ BrainzLab.debug_log("ActiveRecord strict loading instrumentation failed: #{e.message}")
416
+ end
417
+
418
+ # ============================================
419
+ # Deprecated Association Tracking
420
+ # Fired when a deprecated association is accessed
421
+ # ============================================
422
+ def install_deprecated_association_subscriber!
423
+ ActiveSupport::Notifications.subscribe('deprecated_association.active_record') do |*args|
424
+ event = ActiveSupport::Notifications::Event.new(*args)
425
+ handle_deprecated_association(event)
426
+ end
427
+ end
428
+
429
+ def handle_deprecated_association(event)
430
+ payload = event.payload
431
+ owner = payload[:owner]
432
+ reflection = payload[:reflection]
433
+ message = payload[:message]
434
+
435
+ owner_class = owner.is_a?(Class) ? owner.name : owner.class.name
436
+ association_name = reflection.respond_to?(:name) ? reflection.name : reflection.to_s
437
+
438
+ # Add breadcrumb
439
+ BrainzLab::Reflex.add_breadcrumb(
440
+ "Deprecated association: #{owner_class}##{association_name}",
441
+ category: 'db.deprecated_association',
442
+ level: :warning,
443
+ data: {
444
+ owner_class: owner_class,
445
+ association: association_name.to_s,
446
+ message: message&.slice(0, 200)
447
+ }.compact
448
+ )
449
+
450
+ # Log to Recall
451
+ if BrainzLab.configuration.recall_effectively_enabled?
452
+ BrainzLab::Recall.warn(
453
+ "Deprecated association accessed",
454
+ owner_class: owner_class,
455
+ association: association_name.to_s,
456
+ message: message
457
+ )
458
+ end
459
+ rescue StandardError => e
460
+ BrainzLab.debug_log("ActiveRecord deprecated association instrumentation failed: #{e.message}")
461
+ end
462
+
463
+ # ============================================
464
+ # Helper Methods
465
+ # ============================================
71
466
  def extract_operation(sql)
72
467
  return 'query' unless sql
73
468
 
74
- case sql.to_s.strip.upcase
469
+ case sql.to_s.strip
75
470
  when /\ASELECT/i then 'select'
76
471
  when /\AINSERT/i then 'insert'
77
472
  when /\AUPDATE/i then 'update'
78
473
  when /\ADELETE/i then 'delete'
79
- when /\ABEGIN/i, /\ACOMMIT/i, /\AROLLBACK/i then 'transaction'
474
+ when /\ABEGIN/i, /\ASTART TRANSACTION/i then 'transaction.begin'
475
+ when /\ACOMMIT/i then 'transaction.commit'
476
+ when /\AROLLBACK TO SAVEPOINT/i then 'savepoint.rollback'
477
+ when /\AROLLBACK/i then 'transaction.rollback'
478
+ when /\ARELEASE SAVEPOINT/i then 'savepoint.release'
479
+ when /\ASAVEPOINT/i then 'savepoint'
80
480
  else 'query'
81
481
  end
82
482
  end
83
483
 
484
+ def extract_table_from_sql(sql)
485
+ # Extract table name from SELECT queries for N+1 detection
486
+ # Handles: SELECT ... FROM table_name, SELECT ... FROM "table_name", etc.
487
+ return nil unless sql =~ /\ASELECT/i
488
+
489
+ if sql =~ /FROM\s+["'`]?(\w+)["'`]?/i
490
+ ::Regexp.last_match(1)
491
+ end
492
+ end
493
+
84
494
  def skip_query?(payload)
85
495
  # Skip schema queries
86
496
  return true if SCHEMA_QUERIES.include?(payload[:name])
@@ -99,7 +509,6 @@ module BrainzLab
99
509
  return nil unless connection
100
510
 
101
511
  # Rails 8.1+ uses db_config.name on the pool
102
- # Older versions used connection_class but that's removed in Rails 8.1
103
512
  if connection.respond_to?(:pool)
104
513
  pool = connection.pool
105
514
  pool.db_config.name if pool.respond_to?(:db_config) && pool.db_config.respond_to?(:name)
@@ -110,12 +519,36 @@ module BrainzLab
110
519
  nil
111
520
  end
112
521
 
113
- def truncate_sql(sql)
522
+ def extract_adapter_name(connection)
523
+ return nil unless connection
524
+
525
+ if connection.respond_to?(:adapter_name)
526
+ connection.adapter_name.downcase
527
+ elsif connection.respond_to?(:pool) && connection.pool.respond_to?(:db_config)
528
+ connection.pool.db_config.adapter
529
+ end
530
+ rescue StandardError
531
+ nil
532
+ end
533
+
534
+ def extract_database_name(connection)
535
+ return nil unless connection
536
+
537
+ if connection.respond_to?(:pool) && connection.pool.respond_to?(:db_config)
538
+ connection.pool.db_config.database
539
+ elsif connection.respond_to?(:current_database)
540
+ connection.current_database
541
+ end
542
+ rescue StandardError
543
+ nil
544
+ end
545
+
546
+ def truncate_sql(sql, max_length = 500)
114
547
  return nil unless sql
115
548
 
116
549
  truncated = sql.to_s.gsub(/\s+/, ' ').strip
117
- if truncated.length > 500
118
- "#{truncated[0, 497]}..."
550
+ if truncated.length > max_length
551
+ "#{truncated[0, max_length - 3]}..."
119
552
  else
120
553
  truncated
121
554
  end