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,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
|