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.
@@ -0,0 +1,380 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module Instrumentation
5
+ class ActionView
6
+ # Thresholds for slow render detection (in milliseconds)
7
+ SLOW_RENDER_THRESHOLD = 50
8
+ VERY_SLOW_RENDER_THRESHOLD = 200
9
+
10
+ # Only track partials rendered more than this many times
11
+ COLLECTION_TRACKING_THRESHOLD = 10
12
+
13
+ class << self
14
+ def install!
15
+ return unless defined?(::ActionView)
16
+ return if @installed
17
+
18
+ install_render_template_subscriber!
19
+ install_render_partial_subscriber!
20
+ install_render_collection_subscriber!
21
+ install_render_layout_subscriber!
22
+
23
+ @installed = true
24
+ BrainzLab.debug_log('ActionView instrumentation installed')
25
+ end
26
+
27
+ def installed?
28
+ @installed == true
29
+ end
30
+
31
+ private
32
+
33
+ # ============================================
34
+ # Render Template
35
+ # ============================================
36
+ def install_render_template_subscriber!
37
+ ActiveSupport::Notifications.subscribe('render_template.action_view') do |*args|
38
+ event = ActiveSupport::Notifications::Event.new(*args)
39
+ handle_render_template(event)
40
+ end
41
+ end
42
+
43
+ def handle_render_template(event)
44
+ payload = event.payload
45
+ duration = event.duration.round(2)
46
+
47
+ identifier = payload[:identifier]
48
+ layout = payload[:layout]
49
+
50
+ # Extract template name from full path
51
+ template_name = extract_template_name(identifier)
52
+
53
+ # Add breadcrumb for Reflex
54
+ record_render_breadcrumb('template', template_name, duration, layout: layout)
55
+
56
+ # Add span to Pulse
57
+ record_render_span(event, 'template', template_name, duration, layout: layout)
58
+
59
+ # Log slow renders to Recall
60
+ log_slow_render('template', template_name, duration) if duration >= SLOW_RENDER_THRESHOLD
61
+ rescue StandardError => e
62
+ BrainzLab.debug_log("ActionView render_template instrumentation failed: #{e.message}")
63
+ end
64
+
65
+ # ============================================
66
+ # Render Partial
67
+ # ============================================
68
+ def install_render_partial_subscriber!
69
+ ActiveSupport::Notifications.subscribe('render_partial.action_view') do |*args|
70
+ event = ActiveSupport::Notifications::Event.new(*args)
71
+ handle_render_partial(event)
72
+ end
73
+ end
74
+
75
+ def handle_render_partial(event)
76
+ payload = event.payload
77
+ duration = event.duration.round(2)
78
+
79
+ identifier = payload[:identifier]
80
+ template_name = extract_template_name(identifier)
81
+
82
+ # Skip very fast partials to reduce noise
83
+ return if duration < 1 && !payload[:cache_hit]
84
+
85
+ # Add breadcrumb for Reflex
86
+ record_render_breadcrumb('partial', template_name, duration, cached: payload[:cache_hit])
87
+
88
+ # Add span to Pulse
89
+ record_render_span(event, 'partial', template_name, duration, cached: payload[:cache_hit])
90
+
91
+ # Log slow renders to Recall
92
+ log_slow_render('partial', template_name, duration) if duration >= SLOW_RENDER_THRESHOLD
93
+ rescue StandardError => e
94
+ BrainzLab.debug_log("ActionView render_partial instrumentation failed: #{e.message}")
95
+ end
96
+
97
+ # ============================================
98
+ # Render Collection
99
+ # ============================================
100
+ def install_render_collection_subscriber!
101
+ ActiveSupport::Notifications.subscribe('render_collection.action_view') do |*args|
102
+ event = ActiveSupport::Notifications::Event.new(*args)
103
+ handle_render_collection(event)
104
+ end
105
+ end
106
+
107
+ def handle_render_collection(event)
108
+ payload = event.payload
109
+ duration = event.duration.round(2)
110
+
111
+ identifier = payload[:identifier]
112
+ count = payload[:count] || 0
113
+ cache_hits = payload[:cache_hits] || 0
114
+
115
+ template_name = extract_template_name(identifier)
116
+
117
+ # Add breadcrumb for significant collections
118
+ if count >= COLLECTION_TRACKING_THRESHOLD || duration >= SLOW_RENDER_THRESHOLD
119
+ record_collection_breadcrumb(template_name, duration, count, cache_hits)
120
+ end
121
+
122
+ # Add span to Pulse
123
+ record_collection_span(event, template_name, duration, count, cache_hits)
124
+
125
+ # Log slow collection renders to Recall
126
+ if duration >= SLOW_RENDER_THRESHOLD
127
+ log_slow_collection_render(template_name, duration, count, cache_hits)
128
+ end
129
+
130
+ # Track potential N+1 view pattern (many partials rendered)
131
+ track_collection_performance(template_name, count, duration) if count >= 50
132
+ rescue StandardError => e
133
+ BrainzLab.debug_log("ActionView render_collection instrumentation failed: #{e.message}")
134
+ end
135
+
136
+ # ============================================
137
+ # Render Layout
138
+ # ============================================
139
+ def install_render_layout_subscriber!
140
+ ActiveSupport::Notifications.subscribe('render_layout.action_view') do |*args|
141
+ event = ActiveSupport::Notifications::Event.new(*args)
142
+ handle_render_layout(event)
143
+ end
144
+ end
145
+
146
+ def handle_render_layout(event)
147
+ payload = event.payload
148
+ duration = event.duration.round(2)
149
+
150
+ identifier = payload[:identifier]
151
+ layout_name = extract_template_name(identifier)
152
+
153
+ # Only track if significant
154
+ return if duration < 5
155
+
156
+ # Add span to Pulse (layouts are important for understanding request timing)
157
+ if BrainzLab.configuration.pulse_effectively_enabled?
158
+ tracer = BrainzLab::Pulse.tracer
159
+ if tracer.current_trace
160
+ span_data = {
161
+ span_id: SecureRandom.uuid,
162
+ name: "view.layout.#{layout_name}",
163
+ kind: 'view',
164
+ started_at: event.time,
165
+ ended_at: event.end,
166
+ duration_ms: duration,
167
+ error: false,
168
+ data: {
169
+ 'view.type' => 'layout',
170
+ 'view.template' => layout_name
171
+ }
172
+ }
173
+
174
+ tracer.current_spans << span_data
175
+ end
176
+ end
177
+ rescue StandardError => e
178
+ BrainzLab.debug_log("ActionView render_layout instrumentation failed: #{e.message}")
179
+ end
180
+
181
+ # ============================================
182
+ # Recording Helpers
183
+ # ============================================
184
+ def record_render_breadcrumb(type, template_name, duration, layout: nil, cached: nil)
185
+ return unless BrainzLab.configuration.reflex_effectively_enabled?
186
+
187
+ level = case duration
188
+ when 0...SLOW_RENDER_THRESHOLD then :info
189
+ when SLOW_RENDER_THRESHOLD...VERY_SLOW_RENDER_THRESHOLD then :warning
190
+ else :error
191
+ end
192
+
193
+ message = if cached
194
+ "Render #{type}: #{template_name} (cached)"
195
+ else
196
+ "Render #{type}: #{template_name} (#{duration}ms)"
197
+ end
198
+
199
+ BrainzLab::Reflex.add_breadcrumb(
200
+ message,
201
+ category: "view.#{type}",
202
+ level: level,
203
+ data: {
204
+ template: template_name,
205
+ type: type,
206
+ duration_ms: duration,
207
+ layout: layout,
208
+ cached: cached
209
+ }.compact
210
+ )
211
+ end
212
+
213
+ def record_render_span(event, type, template_name, duration, layout: nil, cached: nil)
214
+ return unless BrainzLab.configuration.pulse_effectively_enabled?
215
+
216
+ tracer = BrainzLab::Pulse.tracer
217
+ return unless tracer.current_trace
218
+
219
+ span_data = {
220
+ span_id: SecureRandom.uuid,
221
+ name: "view.#{type}.#{template_name}",
222
+ kind: 'view',
223
+ started_at: event.time,
224
+ ended_at: event.end,
225
+ duration_ms: duration,
226
+ error: false,
227
+ data: {
228
+ 'view.type' => type,
229
+ 'view.template' => template_name,
230
+ 'view.layout' => layout,
231
+ 'view.cached' => cached
232
+ }.compact
233
+ }
234
+
235
+ tracer.current_spans << span_data
236
+ end
237
+
238
+ def record_collection_breadcrumb(template_name, duration, count, cache_hits)
239
+ return unless BrainzLab.configuration.reflex_effectively_enabled?
240
+
241
+ level = case duration
242
+ when 0...SLOW_RENDER_THRESHOLD then :info
243
+ when SLOW_RENDER_THRESHOLD...VERY_SLOW_RENDER_THRESHOLD then :warning
244
+ else :error
245
+ end
246
+
247
+ cache_info = cache_hits > 0 ? " (#{cache_hits}/#{count} cached)" : ""
248
+
249
+ BrainzLab::Reflex.add_breadcrumb(
250
+ "Render collection: #{template_name} x#{count}#{cache_info} (#{duration}ms)",
251
+ category: 'view.collection',
252
+ level: level,
253
+ data: {
254
+ template: template_name,
255
+ count: count,
256
+ cache_hits: cache_hits,
257
+ duration_ms: duration,
258
+ avg_per_item_ms: count > 0 ? (duration / count).round(2) : nil
259
+ }.compact
260
+ )
261
+ end
262
+
263
+ def record_collection_span(event, template_name, duration, count, cache_hits)
264
+ return unless BrainzLab.configuration.pulse_effectively_enabled?
265
+
266
+ tracer = BrainzLab::Pulse.tracer
267
+ return unless tracer.current_trace
268
+
269
+ span_data = {
270
+ span_id: SecureRandom.uuid,
271
+ name: "view.collection.#{template_name}",
272
+ kind: 'view',
273
+ started_at: event.time,
274
+ ended_at: event.end,
275
+ duration_ms: duration,
276
+ error: false,
277
+ data: {
278
+ 'view.type' => 'collection',
279
+ 'view.template' => template_name,
280
+ 'view.count' => count,
281
+ 'view.cache_hits' => cache_hits,
282
+ 'view.avg_per_item_ms' => count > 0 ? (duration / count).round(2) : nil
283
+ }.compact
284
+ }
285
+
286
+ tracer.current_spans << span_data
287
+ end
288
+
289
+ # ============================================
290
+ # Logging Helpers
291
+ # ============================================
292
+ def log_slow_render(type, template_name, duration)
293
+ return unless BrainzLab.configuration.recall_effectively_enabled?
294
+
295
+ level = duration >= VERY_SLOW_RENDER_THRESHOLD ? :error : :warn
296
+
297
+ BrainzLab::Recall.send(
298
+ level,
299
+ "Slow #{type} render: #{template_name} (#{duration}ms)",
300
+ template: template_name,
301
+ type: type,
302
+ duration_ms: duration,
303
+ threshold_exceeded: duration >= VERY_SLOW_RENDER_THRESHOLD ? 'critical' : 'warning'
304
+ )
305
+ end
306
+
307
+ def log_slow_collection_render(template_name, duration, count, cache_hits)
308
+ return unless BrainzLab.configuration.recall_effectively_enabled?
309
+
310
+ level = duration >= VERY_SLOW_RENDER_THRESHOLD ? :error : :warn
311
+
312
+ BrainzLab::Recall.send(
313
+ level,
314
+ "Slow collection render: #{template_name} x#{count} (#{duration}ms)",
315
+ template: template_name,
316
+ type: 'collection',
317
+ count: count,
318
+ cache_hits: cache_hits,
319
+ duration_ms: duration,
320
+ avg_per_item_ms: count > 0 ? (duration / count).round(2) : nil,
321
+ threshold_exceeded: duration >= VERY_SLOW_RENDER_THRESHOLD ? 'critical' : 'warning'
322
+ )
323
+ end
324
+
325
+ def track_collection_performance(template_name, count, duration)
326
+ avg_per_item = count > 0 ? (duration / count).round(2) : 0
327
+
328
+ # If average time per item is high, this might indicate N+1 in the partial
329
+ if avg_per_item > 5 # More than 5ms per item is suspicious
330
+ if BrainzLab.configuration.reflex_effectively_enabled?
331
+ BrainzLab::Reflex.add_breadcrumb(
332
+ "Slow collection items: #{template_name} (#{avg_per_item}ms/item)",
333
+ category: 'view.performance',
334
+ level: :warning,
335
+ data: {
336
+ template: template_name,
337
+ count: count,
338
+ duration_ms: duration,
339
+ avg_per_item_ms: avg_per_item,
340
+ suggestion: 'Consider caching, eager loading, or optimizing the partial'
341
+ }
342
+ )
343
+ end
344
+
345
+ if BrainzLab.configuration.recall_effectively_enabled?
346
+ BrainzLab::Recall.warn(
347
+ "Collection partial may have N+1 or performance issue",
348
+ template: template_name,
349
+ count: count,
350
+ duration_ms: duration,
351
+ avg_per_item_ms: avg_per_item
352
+ )
353
+ end
354
+ end
355
+ end
356
+
357
+ # ============================================
358
+ # Helper Methods
359
+ # ============================================
360
+ def extract_template_name(identifier)
361
+ return 'unknown' unless identifier
362
+
363
+ # Remove the full path and extract just the template name
364
+ # e.g., "/app/views/users/show.html.erb" -> "users/show"
365
+ path = identifier.to_s
366
+
367
+ # Try to extract from app/views path
368
+ if path.include?('app/views/')
369
+ template = path.split('app/views/').last
370
+ # Remove extension
371
+ template.sub(/\.[^.]+\z/, '').sub(/\.[^.]+\z/, '')
372
+ else
373
+ # Fallback: just use the filename
374
+ File.basename(path).sub(/\.[^.]+\z/, '').sub(/\.[^.]+\z/, '')
375
+ end
376
+ end
377
+ end
378
+ end
379
+ end
380
+ end