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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +52 -0
  3. data/LICENSE +26 -0
  4. data/README.md +311 -0
  5. data/lib/brainzlab/configuration.rb +215 -0
  6. data/lib/brainzlab/context.rb +91 -0
  7. data/lib/brainzlab/instrumentation/action_mailer.rb +181 -0
  8. data/lib/brainzlab/instrumentation/active_record.rb +111 -0
  9. data/lib/brainzlab/instrumentation/delayed_job.rb +236 -0
  10. data/lib/brainzlab/instrumentation/elasticsearch.rb +210 -0
  11. data/lib/brainzlab/instrumentation/faraday.rb +182 -0
  12. data/lib/brainzlab/instrumentation/grape.rb +293 -0
  13. data/lib/brainzlab/instrumentation/graphql.rb +251 -0
  14. data/lib/brainzlab/instrumentation/httparty.rb +194 -0
  15. data/lib/brainzlab/instrumentation/mongodb.rb +187 -0
  16. data/lib/brainzlab/instrumentation/net_http.rb +109 -0
  17. data/lib/brainzlab/instrumentation/redis.rb +331 -0
  18. data/lib/brainzlab/instrumentation/sidekiq.rb +264 -0
  19. data/lib/brainzlab/instrumentation.rb +132 -0
  20. data/lib/brainzlab/pulse/client.rb +132 -0
  21. data/lib/brainzlab/pulse/instrumentation.rb +364 -0
  22. data/lib/brainzlab/pulse/propagation.rb +241 -0
  23. data/lib/brainzlab/pulse/provisioner.rb +114 -0
  24. data/lib/brainzlab/pulse/tracer.rb +111 -0
  25. data/lib/brainzlab/pulse.rb +224 -0
  26. data/lib/brainzlab/rails/log_formatter.rb +801 -0
  27. data/lib/brainzlab/rails/log_subscriber.rb +341 -0
  28. data/lib/brainzlab/rails/railtie.rb +590 -0
  29. data/lib/brainzlab/recall/buffer.rb +64 -0
  30. data/lib/brainzlab/recall/client.rb +86 -0
  31. data/lib/brainzlab/recall/logger.rb +118 -0
  32. data/lib/brainzlab/recall/provisioner.rb +113 -0
  33. data/lib/brainzlab/recall.rb +155 -0
  34. data/lib/brainzlab/reflex/breadcrumbs.rb +55 -0
  35. data/lib/brainzlab/reflex/client.rb +85 -0
  36. data/lib/brainzlab/reflex/provisioner.rb +116 -0
  37. data/lib/brainzlab/reflex.rb +374 -0
  38. data/lib/brainzlab/version.rb +5 -0
  39. data/lib/brainzlab-sdk.rb +3 -0
  40. data/lib/brainzlab.rb +140 -0
  41. data/lib/generators/brainzlab/install/install_generator.rb +61 -0
  42. data/lib/generators/brainzlab/install/templates/brainzlab.rb.tt +77 -0
  43. metadata +159 -0
@@ -0,0 +1,801 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module Rails
5
+ class LogFormatter
6
+ ASSET_PATHS = %w[/assets /packs /vite /images /fonts /stylesheets /javascripts].freeze
7
+ ASSET_EXTENSIONS = %w[.css .js .map .png .jpg .jpeg .gif .svg .ico .woff .woff2 .ttf .eot].freeze
8
+ SIMPLE_PATHS = %w[/up /health /healthz /ready /readiness /live /liveness /ping /favicon.ico /apple-touch-icon.png /apple-touch-icon-precomposed.png /robots.txt /sitemap.xml].freeze
9
+ IGNORED_PATHS = %w[/apple-touch-icon.png /apple-touch-icon-precomposed.png /favicon.ico].freeze
10
+
11
+ # Thresholds for highlighting
12
+ SLOW_QUERY_MS = 5.0
13
+ N_PLUS_ONE_THRESHOLD = 3
14
+
15
+ # ANSI color codes
16
+ COLORS = {
17
+ reset: "\e[0m",
18
+ bold: "\e[1m",
19
+ dim: "\e[2m",
20
+ red: "\e[31m",
21
+ green: "\e[32m",
22
+ yellow: "\e[33m",
23
+ blue: "\e[34m",
24
+ magenta: "\e[35m",
25
+ cyan: "\e[36m",
26
+ white: "\e[37m",
27
+ gray: "\e[90m"
28
+ }.freeze
29
+
30
+ # Box drawing characters
31
+ BOX = {
32
+ top_left: "┌",
33
+ top_right: "─",
34
+ bottom_left: "└",
35
+ bottom_right: "─",
36
+ vertical: "│",
37
+ horizontal: "─"
38
+ }.freeze
39
+
40
+ attr_reader :config
41
+
42
+ def initialize(config = {})
43
+ @config = default_config.merge(config)
44
+ @request_data = {}
45
+ end
46
+
47
+ def default_config
48
+ {
49
+ enabled: true,
50
+ colors: $stdout.tty?,
51
+ hide_assets: false,
52
+ hide_ignored: true,
53
+ compact_assets: true,
54
+ show_params: true,
55
+ show_sql_count: true,
56
+ show_sql_details: true,
57
+ show_sql_queries: true, # Show actual SQL queries
58
+ show_views: true,
59
+ slow_query_threshold: SLOW_QUERY_MS,
60
+ n_plus_one_threshold: N_PLUS_ONE_THRESHOLD,
61
+ line_width: detect_terminal_width
62
+ }
63
+ end
64
+
65
+ def detect_terminal_width
66
+ # Try to get terminal width, fallback to 120
67
+ width = ENV["COLUMNS"]&.to_i
68
+ return width if width && width > 0
69
+
70
+ if $stdout.tty? && IO.respond_to?(:console) && IO.console
71
+ _rows, cols = IO.console.winsize
72
+ return cols if cols > 0
73
+ end
74
+
75
+ 120 # Default to 120 for wider output
76
+ rescue
77
+ 120
78
+ end
79
+
80
+ # Called when a request starts
81
+ def start_request(request_id, data = {})
82
+ @request_data[request_id] = {
83
+ started_at: Time.current,
84
+ method: data[:method],
85
+ path: data[:path],
86
+ params: data[:params] || {},
87
+ controller: nil,
88
+ action: nil,
89
+ status: nil,
90
+ duration: nil,
91
+ view_runtime: nil,
92
+ db_runtime: nil,
93
+ sql_queries: [],
94
+ views: [],
95
+ error: nil,
96
+ error_message: nil
97
+ }
98
+ end
99
+
100
+ # Called when controller processes action
101
+ def process_action(request_id, data = {})
102
+ return unless @request_data[request_id]
103
+
104
+ @request_data[request_id].merge!(
105
+ controller: data[:controller],
106
+ action: data[:action],
107
+ status: data[:status],
108
+ duration: data[:duration],
109
+ view_runtime: data[:view_runtime],
110
+ db_runtime: data[:db_runtime]
111
+ )
112
+ end
113
+
114
+ # Called when SQL query is executed
115
+ def sql_query(request_id, name: nil, duration: 0, sql: nil, sql_pattern: nil, cached: false, source: nil)
116
+ return unless @request_data[request_id]
117
+
118
+ @request_data[request_id][:sql_queries] << {
119
+ name: name,
120
+ duration: duration,
121
+ sql: sql,
122
+ sql_pattern: sql_pattern,
123
+ cached: cached,
124
+ source: source
125
+ }
126
+ end
127
+
128
+ # Called when template is rendered
129
+ def render_template(request_id, template: nil, duration: 0, layout: nil)
130
+ return unless @request_data[request_id]
131
+
132
+ @request_data[request_id][:views] << {
133
+ type: :template,
134
+ template: template,
135
+ duration: duration,
136
+ layout: layout
137
+ }
138
+ end
139
+
140
+ # Called when partial is rendered
141
+ def render_partial(request_id, template: nil, duration: 0, count: nil)
142
+ return unless @request_data[request_id]
143
+
144
+ @request_data[request_id][:views] << {
145
+ type: :partial,
146
+ template: template,
147
+ duration: duration,
148
+ count: count
149
+ }
150
+ end
151
+
152
+ # Called when layout is rendered
153
+ def render_layout(request_id, layout: nil, duration: 0)
154
+ return unless @request_data[request_id]
155
+
156
+ @request_data[request_id][:views] << {
157
+ type: :layout,
158
+ template: layout,
159
+ duration: duration
160
+ }
161
+ end
162
+
163
+ # Called when an error occurs
164
+ def error(request_id, exception)
165
+ return unless @request_data[request_id]
166
+
167
+ @request_data[request_id][:error] = exception.class.name
168
+ @request_data[request_id][:error_message] = exception.message
169
+ end
170
+
171
+ # Called when request ends - returns formatted output
172
+ def end_request(request_id)
173
+ data = @request_data.delete(request_id)
174
+ return nil unless data
175
+
176
+ format_request(data)
177
+ end
178
+
179
+ # Format a complete request
180
+ def format_request(data)
181
+ return nil if should_ignore?(data)
182
+ return format_simple(data) if should_be_simple?(data)
183
+
184
+ format_full(data)
185
+ end
186
+
187
+ private
188
+
189
+ def should_ignore?(data)
190
+ return false unless config[:hide_ignored]
191
+
192
+ path = data[:path].to_s.downcase
193
+ IGNORED_PATHS.any? { |p| path == p || path.end_with?(p) }
194
+ end
195
+
196
+ def should_be_simple?(data)
197
+ return true if asset_request?(data[:path])
198
+ return true if simple_path?(data[:path])
199
+
200
+ false
201
+ end
202
+
203
+ def asset_request?(path)
204
+ return false unless path
205
+
206
+ path = path.to_s.downcase
207
+ return true if ASSET_PATHS.any? { |p| path.start_with?(p) }
208
+ return true if ASSET_EXTENSIONS.any? { |ext| path.end_with?(ext) }
209
+
210
+ false
211
+ end
212
+
213
+ def simple_path?(path)
214
+ return false unless path
215
+
216
+ path = path.to_s.downcase
217
+ SIMPLE_PATHS.include?(path)
218
+ end
219
+
220
+ # Format: 09:13:23 GET /assets/app.css → 200 3ms
221
+ def format_simple(data)
222
+ return nil if config[:hide_assets] && asset_request?(data[:path])
223
+
224
+ time = format_time(data[:started_at])
225
+ method = format_method(data[:method])
226
+ path = truncate(data[:path], 40)
227
+ status = format_status(data[:status])
228
+ duration = format_duration(data[:duration])
229
+
230
+ parts = [
231
+ colorize(time, :gray),
232
+ method,
233
+ colorize(path, :white),
234
+ colorize("→", :gray),
235
+ status,
236
+ duration
237
+ ]
238
+
239
+ parts.join(" ") + "\n"
240
+ end
241
+
242
+ # Format full block output
243
+ def format_full(data)
244
+ lines = []
245
+ width = config[:line_width]
246
+
247
+ # Header line
248
+ method_path = "#{data[:method]} #{data[:path]}"
249
+ header = build_header(method_path, data[:error], width)
250
+ lines << header
251
+
252
+ # Status line
253
+ status_text = data[:status] ? "#{data[:status]} #{status_phrase(data[:status])}" : "---"
254
+ lines << build_line("status", format_status_value(data[:status], status_text))
255
+
256
+ # Duration line
257
+ if data[:duration]
258
+ lines << build_line("duration", format_duration_full(data[:duration]))
259
+ end
260
+
261
+ # Database line with query analysis
262
+ if data[:db_runtime] || data[:sql_queries].any?
263
+ db_info = format_db_info(data)
264
+ lines << build_line("db", db_info)
265
+ end
266
+
267
+ # Views line
268
+ if data[:view_runtime]
269
+ lines << build_line("views", "#{data[:view_runtime].round(1)}ms")
270
+ end
271
+
272
+ # Params section (if enabled and present) - TOML style
273
+ if config[:show_params] && data[:params].present?
274
+ params_str = format_params(data[:params])
275
+ if params_str
276
+ lines << build_line("", colorize("[params]", :gray))
277
+ lines << params_str
278
+ end
279
+ end
280
+
281
+ # Error lines
282
+ if data[:error]
283
+ lines << build_line("error", colorize(data[:error], :red))
284
+ if data[:error_message]
285
+ msg = truncate(data[:error_message], width - 15)
286
+ lines << build_line("message", colorize("\"#{msg}\"", :red))
287
+ end
288
+ end
289
+
290
+ # SQL details section (N+1, slow queries)
291
+ if config[:show_sql_details]
292
+ sql_issues = analyze_sql_queries(data[:sql_queries])
293
+ if sql_issues.any?
294
+ lines << build_separator(width)
295
+ sql_issues.each { |issue| lines << issue }
296
+ end
297
+ end
298
+
299
+ # View rendering details
300
+ if config[:show_views] && data[:views].any?
301
+ view_summary = format_view_summary(data[:views])
302
+ if view_summary.any?
303
+ lines << build_separator(width) unless sql_issues&.any?
304
+ view_summary.each { |line| lines << line }
305
+ end
306
+ end
307
+
308
+ # Footer line
309
+ lines << build_footer(width)
310
+
311
+ lines.join("\n") + "\n\n"
312
+ end
313
+
314
+ def analyze_sql_queries(queries)
315
+ issues = []
316
+ threshold = config[:slow_query_threshold] || SLOW_QUERY_MS
317
+ n1_threshold = config[:n_plus_one_threshold] || N_PLUS_ONE_THRESHOLD
318
+
319
+ # Group queries by pattern to detect N+1
320
+ pattern_counts = queries.reject { |q| q[:cached] }.group_by { |q| q[:sql_pattern] }
321
+
322
+ pattern_counts.each do |pattern, matching_queries|
323
+ next unless matching_queries.size >= n1_threshold
324
+ next unless pattern # Skip if no pattern
325
+
326
+ # This is a potential N+1
327
+ sample = matching_queries.first
328
+ source = sample[:source] || "unknown"
329
+ name = sample[:name] || "Query"
330
+
331
+ # Extract table name from pattern
332
+ table_match = pattern.match(/FROM "?(\w+)"?/i)
333
+ table = table_match ? table_match[1] : name
334
+
335
+ issues << build_line("", colorize("N+1", :red) + " " +
336
+ colorize("#{table} × #{matching_queries.size}", :yellow) +
337
+ colorize(" (#{source})", :gray))
338
+
339
+ # Show the query SQL in TOML-like format
340
+ if config[:show_sql_queries] && sample[:sql]
341
+ issues << format_sql_toml(sample[:sql], sample[:duration])
342
+ end
343
+ end
344
+
345
+ # Find slow queries (not cached, not already reported as N+1)
346
+ n1_patterns = pattern_counts.select { |_, qs| qs.size >= n1_threshold }.keys
347
+ slow_queries = queries.reject { |q| q[:cached] || n1_patterns.include?(q[:sql_pattern]) }
348
+ .select { |q| q[:duration] && q[:duration] >= threshold }
349
+ .sort_by { |q| -q[:duration] }
350
+ .first(3)
351
+
352
+ slow_queries.each do |query|
353
+ source = query[:source] || "unknown"
354
+ name = query[:name] || "Query"
355
+ duration = query[:duration].round(1)
356
+
357
+ issues << build_line("", colorize("Slow", :yellow) + " " +
358
+ colorize("#{name} #{duration}ms", :white) +
359
+ colorize(" (#{source})", :gray))
360
+
361
+ # Show the query SQL in TOML-like format
362
+ if config[:show_sql_queries] && query[:sql]
363
+ issues << format_sql_toml(query[:sql], query[:duration])
364
+ end
365
+ end
366
+
367
+ issues
368
+ end
369
+
370
+ def format_sql_toml(sql, duration = nil)
371
+ lines = []
372
+ prefix = colorize("#{BOX[:vertical]} ", :cyan)
373
+ indent = " "
374
+
375
+ # Parse SQL to extract key components
376
+ parsed = parse_sql(sql)
377
+
378
+ # Format as TOML-like structure
379
+ lines << "#{prefix}#{indent}#{colorize('[query]', :gray)}"
380
+
381
+ if parsed[:operation]
382
+ lines << "#{prefix}#{indent}#{colorize('operation', :gray)} = #{colorize("\"#{parsed[:operation]}\"", :green)}"
383
+ end
384
+
385
+ if parsed[:table]
386
+ lines << "#{prefix}#{indent}#{colorize('table', :gray)} = #{colorize("\"#{parsed[:table]}\"", :green)}"
387
+ end
388
+
389
+ if parsed[:columns].any?
390
+ cols = parsed[:columns].first(5).map { |c| "\"#{c}\"" }.join(", ")
391
+ cols += ", ..." if parsed[:columns].size > 5
392
+ lines << "#{prefix}#{indent}#{colorize('columns', :gray)} = [#{colorize(cols, :cyan)}]"
393
+ end
394
+
395
+ if parsed[:conditions].any?
396
+ lines << "#{prefix}#{indent}#{colorize('[query.where]', :gray)}"
397
+ parsed[:conditions].each do |cond|
398
+ lines << "#{prefix}#{indent}#{colorize(cond[:column], :white)} = #{colorize(cond[:value], :yellow)}"
399
+ end
400
+ end
401
+
402
+ if parsed[:order]
403
+ lines << "#{prefix}#{indent}#{colorize('order', :gray)} = #{colorize("\"#{parsed[:order]}\"", :green)}"
404
+ end
405
+
406
+ if parsed[:limit]
407
+ lines << "#{prefix}#{indent}#{colorize('limit', :gray)} = #{colorize(parsed[:limit].to_s, :magenta)}"
408
+ end
409
+
410
+ if duration
411
+ lines << "#{prefix}#{indent}#{colorize('duration_ms', :gray)} = #{colorize(duration.round(2).to_s, :magenta)}"
412
+ end
413
+
414
+ lines.join("\n")
415
+ end
416
+
417
+ def parse_sql(sql)
418
+ result = {
419
+ operation: nil,
420
+ table: nil,
421
+ columns: [],
422
+ conditions: [],
423
+ order: nil,
424
+ limit: nil
425
+ }
426
+
427
+ return result unless sql
428
+
429
+ # Detect operation
430
+ result[:operation] = case sql
431
+ when /^\s*SELECT/i then "SELECT"
432
+ when /^\s*INSERT/i then "INSERT"
433
+ when /^\s*UPDATE/i then "UPDATE"
434
+ when /^\s*DELETE/i then "DELETE"
435
+ else sql.split.first&.upcase
436
+ end
437
+
438
+ # Extract table name
439
+ if (match = sql.match(/FROM\s+"?(\w+)"?/i))
440
+ result[:table] = match[1]
441
+ elsif (match = sql.match(/INTO\s+"?(\w+)"?/i))
442
+ result[:table] = match[1]
443
+ elsif (match = sql.match(/UPDATE\s+"?(\w+)"?/i))
444
+ result[:table] = match[1]
445
+ end
446
+
447
+ # Extract selected columns (for SELECT)
448
+ if (match = sql.match(/SELECT\s+(.+?)\s+FROM/i))
449
+ cols = match[1]
450
+ if cols.strip != "*"
451
+ result[:columns] = cols.split(",").map { |c| c.strip.gsub(/"/, "").split(".").last }
452
+ end
453
+ end
454
+
455
+ # Extract WHERE conditions
456
+ if (where_match = sql.match(/WHERE\s+(.+?)(?:ORDER|LIMIT|GROUP|$)/i))
457
+ where_clause = where_match[1]
458
+ # Parse individual conditions
459
+ where_clause.scan(/"?(\w+)"?\s*=\s*('[^']*'|\$\d+|\d+)/i).each do |col, val|
460
+ result[:conditions] << { column: col, value: val }
461
+ end
462
+ end
463
+
464
+ # Extract ORDER BY
465
+ if (match = sql.match(/ORDER\s+BY\s+"?(\w+)"?\s*(ASC|DESC)?/i))
466
+ result[:order] = "#{match[1]} #{match[2] || 'ASC'}".strip
467
+ end
468
+
469
+ # Extract LIMIT
470
+ if (match = sql.match(/LIMIT\s+(\d+)/i))
471
+ result[:limit] = match[1].to_i
472
+ end
473
+
474
+ result
475
+ end
476
+
477
+ def format_view_summary(views)
478
+ lines = []
479
+
480
+ # Find the main template and layout
481
+ templates = views.select { |v| v[:type] == :template }
482
+ partials = views.select { |v| v[:type] == :partial }
483
+
484
+ # Group partials by name to detect repeated renders
485
+ partial_counts = partials.group_by { |p| p[:template] }
486
+
487
+ templates.each do |template|
488
+ duration = template[:duration] ? " (#{template[:duration].round(1)}ms)" : ""
489
+ lines << build_line("", colorize("View", :cyan) + " " +
490
+ colorize(template[:template], :white) +
491
+ colorize(duration, :gray))
492
+ end
493
+
494
+ # Show partials that were rendered multiple times (potential issue)
495
+ partial_counts.each do |name, renders|
496
+ next if renders.size < 3 # Only show if rendered 3+ times
497
+
498
+ total_duration = renders.sum { |r| r[:duration] || 0 }
499
+ lines << build_line("", colorize("Partial", :yellow) + " " +
500
+ colorize("#{name} × #{renders.size}", :white) +
501
+ colorize(" (#{total_duration.round(1)}ms total)", :gray))
502
+ end
503
+
504
+ lines
505
+ end
506
+
507
+ def build_header(text, has_error, width)
508
+ prefix = "#{BOX[:top_left]}#{BOX[:horizontal]} "
509
+ suffix = has_error ? " #{BOX[:horizontal]} ERROR #{BOX[:horizontal]}" : " "
510
+
511
+ available = width - prefix.length - suffix.length
512
+ text = truncate(text, available)
513
+ padding = BOX[:horizontal] * [available - text.length, 0].max
514
+
515
+ header = "#{prefix}#{text} #{padding}#{suffix}"
516
+ header = header[0..width] if header.length > width + 1
517
+
518
+ if has_error
519
+ colorize(header, :red)
520
+ else
521
+ colorize(header, :cyan)
522
+ end
523
+ end
524
+
525
+ def build_line(key, value)
526
+ prefix = colorize("#{BOX[:vertical]} ", :cyan)
527
+ if key.empty?
528
+ "#{prefix}#{value}"
529
+ else
530
+ key_formatted = colorize(key.ljust(8), :gray)
531
+ "#{prefix}#{key_formatted} = #{value}"
532
+ end
533
+ end
534
+
535
+ def build_separator(width)
536
+ colorize("#{BOX[:vertical]} #{BOX[:horizontal] * (width - 4)}", :cyan)
537
+ end
538
+
539
+ def build_footer(width)
540
+ footer = "#{BOX[:bottom_left]}#{BOX[:horizontal] * (width - 1)}"
541
+ colorize(footer, :cyan)
542
+ end
543
+
544
+ def format_time(time)
545
+ return "--:--:--" unless time
546
+
547
+ time.strftime("%H:%M:%S")
548
+ end
549
+
550
+ def format_method(method)
551
+ method = method.to_s.upcase
552
+ color = case method
553
+ when "GET" then :green
554
+ when "POST" then :blue
555
+ when "PUT", "PATCH" then :yellow
556
+ when "DELETE" then :red
557
+ else :white
558
+ end
559
+ colorize(method.ljust(6), color)
560
+ end
561
+
562
+ def format_status(status)
563
+ return colorize("---", :gray) unless status
564
+
565
+ color = case status
566
+ when 200..299 then :green
567
+ when 300..399 then :cyan
568
+ when 400..499 then :yellow
569
+ when 500..599 then :red
570
+ else :white
571
+ end
572
+ colorize(status.to_s, color)
573
+ end
574
+
575
+ def format_status_value(status, text)
576
+ color = case status
577
+ when 200..299 then :green
578
+ when 300..399 then :cyan
579
+ when 400..499 then :yellow
580
+ when 500..599 then :red
581
+ else :white
582
+ end
583
+ colorize(text, color)
584
+ end
585
+
586
+ def format_duration(ms)
587
+ return colorize("--", :gray) unless ms
588
+
589
+ formatted = if ms < 1
590
+ "<1ms"
591
+ elsif ms < 1000
592
+ "#{ms.round}ms"
593
+ else
594
+ "#{(ms / 1000.0).round(1)}s"
595
+ end
596
+
597
+ color = if ms < 100
598
+ :green
599
+ elsif ms < 500
600
+ :yellow
601
+ else
602
+ :red
603
+ end
604
+ colorize(formatted, color)
605
+ end
606
+
607
+ def format_duration_full(ms)
608
+ formatted = format_duration(ms)
609
+ # Remove color codes for comparison
610
+ ms_value = ms || 0
611
+ if ms_value < 100
612
+ formatted
613
+ elsif ms_value < 500
614
+ "#{formatted} #{colorize('(slow)', :yellow)}"
615
+ else
616
+ "#{formatted} #{colorize('(very slow)', :red)}"
617
+ end
618
+ end
619
+
620
+ def format_db_info(data)
621
+ parts = []
622
+
623
+ if data[:db_runtime]
624
+ parts << "#{data[:db_runtime].round(1)}ms"
625
+ end
626
+
627
+ if config[:show_sql_count]
628
+ queries = data[:sql_queries] || []
629
+ total = queries.size
630
+ cached = queries.count { |q| q[:cached] }
631
+ non_cached = total - cached
632
+
633
+ query_text = "#{non_cached} #{non_cached == 1 ? 'query' : 'queries'}"
634
+ if cached.positive?
635
+ query_text += ", #{cached} cached"
636
+ end
637
+ parts << "(#{query_text})"
638
+ end
639
+
640
+ parts.join(" ")
641
+ end
642
+
643
+ def format_params(params)
644
+ return nil if params.empty?
645
+
646
+ # Filter out controller, action, and duplicate keys
647
+ filtered = params.except("controller", "action", :controller, :action)
648
+
649
+ # Skip 'ingest' if 'logs' exists (they're the same data)
650
+ if filtered.key?("logs") || filtered.key?(:logs)
651
+ filtered = filtered.except("ingest", :ingest)
652
+ end
653
+
654
+ return nil if filtered.empty?
655
+
656
+ # Format as TOML-like structure
657
+ format_params_toml(filtered)
658
+ end
659
+
660
+ def hash_like?(obj)
661
+ obj.is_a?(Hash) || obj.respond_to?(:to_h) && obj.respond_to?(:each)
662
+ end
663
+
664
+ def format_params_toml(params, prefix = "", depth = 0)
665
+ lines = []
666
+ line_prefix = colorize("#{BOX[:vertical]} ", :cyan)
667
+ indent = " " + (" " * depth)
668
+
669
+ params.each do |key, value|
670
+ full_key = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
671
+
672
+ case value
673
+ when Hash, ActionController::Parameters
674
+ value_hash = value.to_h rescue value
675
+ if value_hash.keys.length <= 3 && value_hash.values.all? { |v| !hash_like?(v) && !v.is_a?(Array) }
676
+ # Compact inline hash for simple cases
677
+ inline = value_hash.map { |k, v| "#{k} = #{format_value(v)}" }.join(", ")
678
+ lines << "#{line_prefix}#{indent}#{colorize(full_key, :white)} = { #{inline} }"
679
+ else
680
+ # Nested section - expand fully
681
+ lines << "#{line_prefix}#{indent}#{colorize("[#{full_key}]", :gray)}"
682
+ value_hash.each do |k, v|
683
+ if hash_like?(v)
684
+ # Recursively format nested hashes
685
+ lines << format_hash_nested(v.to_h, "#{full_key}.#{k}", depth + 1)
686
+ elsif v.is_a?(Array) && hash_like?(v.first)
687
+ lines << "#{line_prefix}#{indent} #{colorize("[[#{full_key}.#{k}]]", :gray)} #{colorize("# #{v.length} items", :gray)}"
688
+ if v.first
689
+ first_hash = v.first.to_h rescue v.first
690
+ first_hash.each do |nested_k, nested_v|
691
+ lines << "#{line_prefix}#{indent} #{colorize(nested_k.to_s, :white)} = #{format_value(nested_v)}"
692
+ end
693
+ end
694
+ else
695
+ lines << "#{line_prefix}#{indent} #{colorize(k.to_s, :white)} = #{format_value(v)}"
696
+ end
697
+ end
698
+ end
699
+ when Array
700
+ if value.length <= 5 && value.all? { |v| !hash_like?(v) && !v.is_a?(Array) }
701
+ # Compact inline array
702
+ arr = value.map { |v| format_value(v) }.join(", ")
703
+ lines << "#{line_prefix}#{indent}#{colorize(full_key, :white)} = [#{arr}]"
704
+ elsif hash_like?(value.first)
705
+ # Array of hashes (like logs array) - show each item
706
+ lines << "#{line_prefix}#{indent}#{colorize("[[#{full_key}]]", :gray)} #{colorize("# #{value.length} items", :gray)}"
707
+ # Show first item fully expanded
708
+ if value.first
709
+ first_item = value.first.to_h rescue value.first
710
+ first_item.each do |k, v|
711
+ if hash_like?(v)
712
+ # Expand nested hash fully
713
+ nested_hash = v.to_h rescue v
714
+ lines << "#{line_prefix}#{indent} #{colorize("[#{k}]", :gray)}"
715
+ nested_hash.each do |nested_k, nested_v|
716
+ lines << "#{line_prefix}#{indent} #{colorize(nested_k.to_s, :white)} = #{format_value(nested_v)}"
717
+ end
718
+ else
719
+ lines << "#{line_prefix}#{indent} #{colorize(k.to_s, :white)} = #{format_value(v)}"
720
+ end
721
+ end
722
+ end
723
+ else
724
+ # Large array of primitives
725
+ arr = value.first(5).map { |v| format_value(v) }.join(", ")
726
+ arr += ", ..." if value.length > 5
727
+ lines << "#{line_prefix}#{indent}#{colorize(full_key, :white)} = [#{arr}] #{colorize("# #{value.length} items", :gray)}"
728
+ end
729
+ else
730
+ lines << "#{line_prefix}#{indent}#{colorize(full_key, :white)} = #{format_value(value)}"
731
+ end
732
+ end
733
+
734
+ lines.join("\n")
735
+ end
736
+
737
+ def format_hash_nested(hash, prefix, depth)
738
+ lines = []
739
+ line_prefix = colorize("#{BOX[:vertical]} ", :cyan)
740
+ indent = " " + (" " * depth)
741
+
742
+ lines << "#{line_prefix}#{indent}#{colorize("[#{prefix}]", :gray)}"
743
+ hash.each do |k, v|
744
+ if v.is_a?(Hash)
745
+ lines << format_hash_nested(v, "#{prefix}.#{k}", depth + 1)
746
+ else
747
+ lines << "#{line_prefix}#{indent} #{colorize(k.to_s, :white)} = #{format_value(v)}"
748
+ end
749
+ end
750
+
751
+ lines.join("\n")
752
+ end
753
+
754
+ def format_value(value)
755
+ case value
756
+ when String
757
+ if value.length > 100
758
+ colorize("\"#{value[0..97]}...\"", :green)
759
+ else
760
+ colorize("\"#{value}\"", :green)
761
+ end
762
+ when Integer, Float
763
+ colorize(value.to_s, :magenta)
764
+ when TrueClass, FalseClass
765
+ colorize(value.to_s, :cyan)
766
+ when NilClass
767
+ colorize("null", :gray)
768
+ when Hash
769
+ items = value.first(3).map { |k, v| "#{k} = #{format_value(v)}" }.join(", ")
770
+ items += ", ..." if value.keys.length > 3
771
+ "{ #{items} }"
772
+ when Array
773
+ if value.length <= 3
774
+ "[#{value.map { |v| format_value(v) }.join(", ")}]"
775
+ else
776
+ "[#{value.first(3).map { |v| format_value(v) }.join(", ")}, ...] #{colorize("# #{value.length} items", :gray)}"
777
+ end
778
+ else
779
+ colorize(value.to_s, :white)
780
+ end
781
+ end
782
+
783
+ def status_phrase(status)
784
+ Rack::Utils::HTTP_STATUS_CODES[status] || "Unknown"
785
+ end
786
+
787
+ def truncate(text, length)
788
+ return text if text.nil? || text.length <= length
789
+
790
+ "#{text[0..(length - 4)]}..."
791
+ end
792
+
793
+ def colorize(text, color)
794
+ return text unless config[:colors]
795
+ return text unless COLORS[color]
796
+
797
+ "#{COLORS[color]}#{text}#{COLORS[:reset]}"
798
+ end
799
+ end
800
+ end
801
+ end