brainzlab 0.1.1 → 0.1.3

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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +6 -21
  3. data/README.md +24 -2
  4. data/lib/brainzlab/beacon/client.rb +207 -0
  5. data/lib/brainzlab/beacon/provisioner.rb +44 -0
  6. data/lib/brainzlab/beacon.rb +215 -0
  7. data/lib/brainzlab/configuration.rb +372 -32
  8. data/lib/brainzlab/context.rb +2 -3
  9. data/lib/brainzlab/cortex/cache.rb +59 -0
  10. data/lib/brainzlab/cortex/client.rb +139 -0
  11. data/lib/brainzlab/cortex/provisioner.rb +49 -0
  12. data/lib/brainzlab/cortex.rb +223 -0
  13. data/lib/brainzlab/dendrite/client.rb +230 -0
  14. data/lib/brainzlab/dendrite/provisioner.rb +44 -0
  15. data/lib/brainzlab/dendrite.rb +195 -0
  16. data/lib/brainzlab/devtools/assets/devtools.css +1106 -0
  17. data/lib/brainzlab/devtools/assets/devtools.js +322 -0
  18. data/lib/brainzlab/devtools/assets/logo.svg +6 -0
  19. data/lib/brainzlab/devtools/assets/templates/debug_panel.html.erb +500 -0
  20. data/lib/brainzlab/devtools/assets/templates/error_page.html.erb +1086 -0
  21. data/lib/brainzlab/devtools/data/collector.rb +248 -0
  22. data/lib/brainzlab/devtools/middleware/asset_server.rb +63 -0
  23. data/lib/brainzlab/devtools/middleware/database_handler.rb +177 -0
  24. data/lib/brainzlab/devtools/middleware/debug_panel.rb +126 -0
  25. data/lib/brainzlab/devtools/middleware/error_page.rb +377 -0
  26. data/lib/brainzlab/devtools/renderers/debug_panel_renderer.rb +159 -0
  27. data/lib/brainzlab/devtools/renderers/error_page_renderer.rb +98 -0
  28. data/lib/brainzlab/devtools.rb +75 -0
  29. data/lib/brainzlab/flux/buffer.rb +96 -0
  30. data/lib/brainzlab/flux/client.rb +68 -0
  31. data/lib/brainzlab/flux/provisioner.rb +57 -0
  32. data/lib/brainzlab/flux.rb +174 -0
  33. data/lib/brainzlab/instrumentation/action_mailer.rb +14 -13
  34. data/lib/brainzlab/instrumentation/active_record.rb +28 -13
  35. data/lib/brainzlab/instrumentation/aws.rb +183 -0
  36. data/lib/brainzlab/instrumentation/dalli.rb +108 -0
  37. data/lib/brainzlab/instrumentation/delayed_job.rb +27 -29
  38. data/lib/brainzlab/instrumentation/elasticsearch.rb +23 -24
  39. data/lib/brainzlab/instrumentation/excon.rb +152 -0
  40. data/lib/brainzlab/instrumentation/faraday.rb +3 -4
  41. data/lib/brainzlab/instrumentation/good_job.rb +102 -0
  42. data/lib/brainzlab/instrumentation/grape.rb +24 -24
  43. data/lib/brainzlab/instrumentation/graphql.rb +24 -23
  44. data/lib/brainzlab/instrumentation/httparty.rb +13 -14
  45. data/lib/brainzlab/instrumentation/mongodb.rb +7 -7
  46. data/lib/brainzlab/instrumentation/net_http.rb +6 -6
  47. data/lib/brainzlab/instrumentation/redis.rb +14 -21
  48. data/lib/brainzlab/instrumentation/resque.rb +114 -0
  49. data/lib/brainzlab/instrumentation/sidekiq.rb +29 -28
  50. data/lib/brainzlab/instrumentation/solid_queue.rb +194 -0
  51. data/lib/brainzlab/instrumentation/stripe.rb +163 -0
  52. data/lib/brainzlab/instrumentation/typhoeus.rb +106 -0
  53. data/lib/brainzlab/instrumentation.rb +84 -12
  54. data/lib/brainzlab/nerve/client.rb +215 -0
  55. data/lib/brainzlab/nerve/provisioner.rb +44 -0
  56. data/lib/brainzlab/nerve.rb +219 -0
  57. data/lib/brainzlab/pulse/client.rb +15 -11
  58. data/lib/brainzlab/pulse/instrumentation.rb +90 -53
  59. data/lib/brainzlab/pulse/propagation.rb +29 -29
  60. data/lib/brainzlab/pulse/provisioner.rb +12 -12
  61. data/lib/brainzlab/pulse/tracer.rb +4 -4
  62. data/lib/brainzlab/pulse.rb +14 -14
  63. data/lib/brainzlab/rails/log_formatter.rb +127 -121
  64. data/lib/brainzlab/rails/log_subscriber.rb +70 -77
  65. data/lib/brainzlab/rails/railtie.rb +96 -86
  66. data/lib/brainzlab/recall/buffer.rb +1 -1
  67. data/lib/brainzlab/recall/client.rb +14 -10
  68. data/lib/brainzlab/recall/logger.rb +16 -18
  69. data/lib/brainzlab/recall/provisioner.rb +29 -12
  70. data/lib/brainzlab/recall.rb +14 -11
  71. data/lib/brainzlab/reflex/breadcrumbs.rb +2 -2
  72. data/lib/brainzlab/reflex/client.rb +14 -10
  73. data/lib/brainzlab/reflex/provisioner.rb +12 -12
  74. data/lib/brainzlab/reflex.rb +31 -31
  75. data/lib/brainzlab/sentinel/client.rb +216 -0
  76. data/lib/brainzlab/sentinel/provisioner.rb +44 -0
  77. data/lib/brainzlab/sentinel.rb +165 -0
  78. data/lib/brainzlab/signal/client.rb +60 -0
  79. data/lib/brainzlab/signal/provisioner.rb +55 -0
  80. data/lib/brainzlab/signal.rb +136 -0
  81. data/lib/brainzlab/synapse/client.rb +288 -0
  82. data/lib/brainzlab/synapse/provisioner.rb +44 -0
  83. data/lib/brainzlab/synapse.rb +270 -0
  84. data/lib/brainzlab/utilities/circuit_breaker.rb +261 -0
  85. data/lib/brainzlab/utilities/health_check.rb +294 -0
  86. data/lib/brainzlab/utilities/log_formatter.rb +254 -0
  87. data/lib/brainzlab/utilities/rate_limiter.rb +230 -0
  88. data/lib/brainzlab/utilities.rb +17 -0
  89. data/lib/brainzlab/vault/cache.rb +80 -0
  90. data/lib/brainzlab/vault/client.rb +196 -0
  91. data/lib/brainzlab/vault/provisioner.rb +49 -0
  92. data/lib/brainzlab/vault.rb +262 -0
  93. data/lib/brainzlab/version.rb +1 -1
  94. data/lib/brainzlab/vision/client.rb +128 -0
  95. data/lib/brainzlab/vision/provisioner.rb +136 -0
  96. data/lib/brainzlab/vision.rb +155 -0
  97. data/lib/brainzlab-sdk.rb +1 -1
  98. data/lib/brainzlab.rb +112 -13
  99. data/lib/generators/brainzlab/install/install_generator.rb +29 -27
  100. metadata +60 -1
@@ -0,0 +1,377 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module DevTools
5
+ module Middleware
6
+ class ErrorPage
7
+ def initialize(app)
8
+ @app = app
9
+ @renderer = Renderers::ErrorPageRenderer.new
10
+ end
11
+
12
+ def call(env)
13
+ return @app.call(env) unless should_handle?(env)
14
+
15
+ begin
16
+ status, headers, body = @app.call(env)
17
+
18
+ # Check if this is an error response that we should intercept
19
+ if status >= 400 && html_response?(headers) && !json_request?(env)
20
+ # Check if this looks like Rails' default error page
21
+ body_content = collect_body(body)
22
+ if body_content.include?('Action Controller: Exception caught') || body_content.include?('background: #C00')
23
+ # Extract exception info from the page
24
+ exception_info = extract_exception_from_html(body_content)
25
+ if exception_info
26
+ data = collect_debug_data_from_info(env, exception_info)
27
+ return render_error_page_from_info(exception_info, data)
28
+ end
29
+ end
30
+ end
31
+
32
+ [status, headers, body]
33
+ rescue Exception => e
34
+ # Don't intercept if request wants JSON
35
+ return raise_exception(e) if json_request?(env)
36
+
37
+ # Still capture to Reflex if available
38
+ capture_to_reflex(e)
39
+
40
+ # Collect debug data and render branded error page
41
+ data = collect_debug_data(env, e)
42
+ render_error_page(e, data)
43
+ end
44
+ end
45
+
46
+ def html_response?(headers)
47
+ # Handle both uppercase and lowercase header names
48
+ content_type = headers['Content-Type'] || headers['content-type'] || ''
49
+ content_type.to_s.downcase.include?('text/html')
50
+ end
51
+
52
+ def extract_exception_from_html(body)
53
+ # Try to extract exception class and message from Rails error page
54
+ if (match = body.match(%r{<h1>([^<]+)</h1>}))
55
+ error_title = match[1]
56
+ # Extract the exception message from the page
57
+ if (msg_match = body.match(%r{<pre[^>]*>([^<]+)</pre>}))
58
+ error_message = msg_match[1]
59
+ end
60
+
61
+ # Try to extract backtrace from Rails 8 format
62
+ # Format: <a class="trace-frames ...">path/to/file.rb:123:in 'method'</a>
63
+ backtrace = []
64
+ body.scan(%r{<a[^>]*class="trace-frames[^"]*"[^>]*>\s*([^<]+)\s*</a>}m) do |trace_match|
65
+ line = trace_match[0].strip
66
+ # Decode HTML entities
67
+ line = line.gsub('&#39;', "'").gsub('&quot;', '"').gsub('&amp;', '&').gsub('&lt;', '<').gsub('&gt;', '>')
68
+ backtrace << line unless line.empty?
69
+ end
70
+
71
+ {
72
+ class_name: decode_html_entities(error_title.strip),
73
+ message: decode_html_entities(error_message&.strip || error_title.strip),
74
+ backtrace: backtrace
75
+ }
76
+ end
77
+ end
78
+
79
+ def decode_html_entities(str)
80
+ return str unless str
81
+
82
+ str.gsub('&#39;', "'")
83
+ .gsub('&quot;', '"')
84
+ .gsub('&amp;', '&')
85
+ .gsub('&lt;', '<')
86
+ .gsub('&gt;', '>')
87
+ .gsub('&nbsp;', ' ')
88
+ end
89
+
90
+ def collect_debug_data_from_info(env, info)
91
+ context = defined?(BrainzLab::Context) ? BrainzLab::Context.current : nil
92
+ collector_data = Data::Collector.get_request_data
93
+
94
+ backtrace = (info[:backtrace] || []).map do |line|
95
+ parsed = parse_backtrace_line(line)
96
+ parsed[:in_app] = in_app_frame?(parsed[:file])
97
+ parsed
98
+ end
99
+
100
+ # Extract source from the first in-app frame
101
+ source_extract = extract_source_from_backtrace(info[:backtrace] || [])
102
+
103
+ {
104
+ exception: nil,
105
+ exception_class: info[:class_name],
106
+ exception_message: info[:message],
107
+ backtrace: backtrace,
108
+ request: build_request_info(env, context),
109
+ context: build_context_info(context),
110
+ sql_queries: collector_data.dig(:database, :queries) || [],
111
+ environment: collect_environment_info,
112
+ source_extract: source_extract
113
+ }
114
+ end
115
+
116
+ def render_error_page_from_info(info, data)
117
+ # Create a simple exception-like object
118
+ exception = StandardError.new(info[:message])
119
+ exception.define_singleton_method(:class) do
120
+ Class.new(StandardError) do
121
+ define_singleton_method(:name) { info[:class_name] }
122
+ end
123
+ end
124
+
125
+ data[:exception] = exception
126
+ html = @renderer.render(exception, data)
127
+
128
+ [
129
+ 500,
130
+ {
131
+ 'Content-Type' => 'text/html; charset=utf-8',
132
+ 'Content-Length' => html.bytesize.to_s,
133
+ 'X-Content-Type-Options' => 'nosniff'
134
+ },
135
+ [html]
136
+ ]
137
+ end
138
+
139
+ private
140
+
141
+ def collect_body(body)
142
+ full_body = +''
143
+ body.each { |part| full_body << part }
144
+ body.close if body.respond_to?(:close)
145
+ full_body
146
+ end
147
+
148
+ def should_handle?(env)
149
+ return false unless DevTools.error_page_enabled?
150
+ return false unless DevTools.allowed_environment?
151
+ return false unless DevTools.allowed_ip?(extract_ip(env))
152
+
153
+ true
154
+ end
155
+
156
+ def extract_ip(env)
157
+ forwarded = env['HTTP_X_FORWARDED_FOR']
158
+ return forwarded.split(',').first.strip if forwarded
159
+
160
+ env['REMOTE_ADDR']
161
+ end
162
+
163
+ def json_request?(env)
164
+ accept = env['HTTP_ACCEPT'] || ''
165
+ content_type = env['CONTENT_TYPE'] || ''
166
+
167
+ accept.include?('application/json') ||
168
+ content_type.include?('application/json') ||
169
+ env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
170
+ end
171
+
172
+ def capture_to_reflex(exception)
173
+ return unless defined?(BrainzLab::Reflex)
174
+
175
+ BrainzLab::Reflex.capture(exception)
176
+ rescue StandardError
177
+ # Ignore errors in error capturing
178
+ end
179
+
180
+ def raise_exception(exception)
181
+ raise exception
182
+ end
183
+
184
+ def collect_debug_data(env, exception)
185
+ context = defined?(BrainzLab::Context) ? BrainzLab::Context.current : nil
186
+ collector_data = Data::Collector.get_request_data
187
+
188
+ {
189
+ exception: exception,
190
+ backtrace: format_backtrace(exception),
191
+ request: build_request_info(env, context),
192
+ context: build_context_info(context),
193
+ sql_queries: collector_data.dig(:database, :queries) || [],
194
+ environment: collect_environment_info,
195
+ source_extract: extract_source_lines(exception)
196
+ }
197
+ end
198
+
199
+ def build_request_info(env, context)
200
+ request = defined?(ActionDispatch::Request) ? ActionDispatch::Request.new(env) : nil
201
+
202
+ {
203
+ method: request&.request_method || env['REQUEST_METHOD'],
204
+ path: request&.path || env['PATH_INFO'],
205
+ url: request&.url || env['REQUEST_URI'],
206
+ params: scrub_params(context&.request_params || extract_params(env)),
207
+ headers: extract_headers(env),
208
+ session: {}
209
+ }
210
+ end
211
+
212
+ def build_context_info(context)
213
+ {
214
+ controller: context&.controller,
215
+ action: context&.action,
216
+ request_id: context&.request_id,
217
+ user: context&.user
218
+ }
219
+ end
220
+
221
+ def extract_params(env)
222
+ return {} unless defined?(Rack::Request)
223
+
224
+ Rack::Request.new(env).params
225
+ rescue StandardError
226
+ {}
227
+ end
228
+
229
+ def extract_headers(env)
230
+ headers = {}
231
+ env.each do |key, value|
232
+ if key.start_with?('HTTP_')
233
+ header_name = key.sub('HTTP_', '').split('_').map(&:capitalize).join('-')
234
+ headers[header_name] = value
235
+ end
236
+ end
237
+ headers
238
+ end
239
+
240
+ def scrub_params(params)
241
+ return {} unless params.is_a?(Hash)
242
+
243
+ scrub_fields = BrainzLab.configuration.scrub_fields.map(&:to_s)
244
+
245
+ params.transform_values.with_index do |(key, value), _|
246
+ if scrub_fields.include?(key.to_s.downcase)
247
+ '[FILTERED]'
248
+ elsif value.is_a?(Hash)
249
+ scrub_params(value)
250
+ else
251
+ value
252
+ end
253
+ end
254
+ rescue StandardError
255
+ params
256
+ end
257
+
258
+ def format_backtrace(exception)
259
+ (exception.backtrace || []).first(50).map do |line|
260
+ parsed = parse_backtrace_line(line)
261
+ parsed[:in_app] = in_app_frame?(parsed[:file])
262
+ parsed
263
+ end
264
+ end
265
+
266
+ def parse_backtrace_line(line)
267
+ match = line.match(/\A(.+):(\d+)(?::in `(.+)')?/)
268
+ return { file: line, line: 0, function: nil, raw: line } unless match
269
+
270
+ {
271
+ file: match[1],
272
+ line: match[2].to_i,
273
+ function: match[3],
274
+ raw: line
275
+ }
276
+ end
277
+
278
+ def in_app_frame?(file)
279
+ return false unless file
280
+
281
+ file.include?('/app/') && !file.include?('/vendor/') && !file.include?('/gems/')
282
+ end
283
+
284
+ def extract_source_from_backtrace(backtrace_lines)
285
+ return nil if backtrace_lines.empty?
286
+
287
+ # Find the first in-app frame
288
+ target_line = backtrace_lines.find { |line| in_app_frame?(line.split(':').first) }
289
+ target_line ||= backtrace_lines.first
290
+
291
+ match = target_line.match(/\A(.+):(\d+)/)
292
+ return nil unless match
293
+
294
+ file = match[1]
295
+ line_number = match[2].to_i
296
+ return nil unless File.exist?(file)
297
+
298
+ lines = File.readlines(file)
299
+ start_line = [line_number - 6, 0].max
300
+ end_line = [line_number + 4, lines.length - 1].min
301
+
302
+ {
303
+ file: file,
304
+ line_number: line_number,
305
+ lines: lines[start_line..end_line].map.with_index do |content, idx|
306
+ {
307
+ number: start_line + idx + 1,
308
+ content: content.chomp,
309
+ highlight: (start_line + idx + 1) == line_number
310
+ }
311
+ end
312
+ }
313
+ rescue StandardError
314
+ nil
315
+ end
316
+
317
+ def extract_source_lines(exception)
318
+ return nil unless exception.backtrace&.any?
319
+
320
+ # Find the first in-app frame (application code, not gems/framework)
321
+ target_line = exception.backtrace.find { |line| in_app_frame?(line.split(':').first) }
322
+ # Fall back to first frame if no in-app frame found
323
+ target_line ||= exception.backtrace.first
324
+
325
+ match = target_line.match(/\A(.+):(\d+)/)
326
+ return nil unless match
327
+
328
+ file = match[1]
329
+ line_number = match[2].to_i
330
+ return nil unless File.exist?(file)
331
+
332
+ lines = File.readlines(file)
333
+ start_line = [line_number - 6, 0].max
334
+ end_line = [line_number + 4, lines.length - 1].min
335
+
336
+ {
337
+ file: file,
338
+ line_number: line_number,
339
+ lines: lines[start_line..end_line].map.with_index do |content, idx|
340
+ {
341
+ number: start_line + idx + 1,
342
+ content: content.chomp,
343
+ highlight: (start_line + idx + 1) == line_number
344
+ }
345
+ end
346
+ }
347
+ rescue StandardError
348
+ nil
349
+ end
350
+
351
+ def collect_environment_info
352
+ {
353
+ rails_version: defined?(::Rails::VERSION::STRING) ? ::Rails::VERSION::STRING : 'N/A',
354
+ ruby_version: RUBY_VERSION,
355
+ env: BrainzLab.configuration.environment,
356
+ server: ENV['SERVER_SOFTWARE'] || 'Unknown',
357
+ pid: Process.pid
358
+ }
359
+ end
360
+
361
+ def render_error_page(exception, data)
362
+ html = @renderer.render(exception, data)
363
+
364
+ [
365
+ 500,
366
+ {
367
+ 'Content-Type' => 'text/html; charset=utf-8',
368
+ 'Content-Length' => html.bytesize.to_s,
369
+ 'X-Content-Type-Options' => 'nosniff'
370
+ },
371
+ [html]
372
+ ]
373
+ end
374
+ end
375
+ end
376
+ end
377
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'cgi'
5
+ require 'json'
6
+
7
+ module BrainzLab
8
+ module DevTools
9
+ module Renderers
10
+ class DebugPanelRenderer
11
+ def initialize
12
+ @template_path = File.join(DevTools::ASSETS_PATH, 'templates', 'debug_panel.html.erb')
13
+ # Cache compiled ERB template to avoid file I/O on every request
14
+ @cached_erb = nil
15
+ @template_mtime = nil
16
+ end
17
+
18
+ def render(data)
19
+ erb = cached_erb
20
+
21
+ # Make data available to template
22
+ @data = data
23
+ @timing = data[:timing] || {}
24
+ @request = data[:request] || {}
25
+ @controller = data[:controller] || {}
26
+ @response = data[:response] || {}
27
+ @database = data[:database] || {}
28
+ @views = data[:views] || {}
29
+ @logs = data[:logs] || []
30
+ @memory = data[:memory] || {}
31
+ @user = data[:user]
32
+ @breadcrumbs = data[:breadcrumbs] || []
33
+ @expand_by_default = DevTools.expand_by_default?
34
+ @panel_position = DevTools.panel_position
35
+
36
+ erb.result(binding)
37
+ end
38
+
39
+ private
40
+
41
+ def h(text)
42
+ CGI.escapeHTML(text.to_s)
43
+ end
44
+
45
+ def asset_url(file)
46
+ "#{DevTools.asset_path}/#{file}"
47
+ end
48
+
49
+ def json_pretty(obj)
50
+ return '' if obj.nil? || (obj.respond_to?(:empty?) && obj.empty?)
51
+
52
+ JSON.pretty_generate(obj)
53
+ rescue StandardError
54
+ obj.inspect
55
+ end
56
+
57
+ def truncate(text, length = 80)
58
+ return '' unless text
59
+
60
+ text = text.to_s
61
+ text.length > length ? "#{text[0...length]}..." : text
62
+ end
63
+
64
+ def format_duration(ms)
65
+ return '0ms' unless ms
66
+
67
+ if ms >= 1000
68
+ "#{(ms / 1000.0).round(2)}s"
69
+ else
70
+ "#{ms.round(2)}ms"
71
+ end
72
+ end
73
+
74
+ def duration_class(ms)
75
+ return '' unless ms
76
+
77
+ if ms > 1000
78
+ 'very-slow'
79
+ elsif ms > 500
80
+ 'slow'
81
+ elsif ms > 200
82
+ 'moderate'
83
+ else
84
+ ''
85
+ end
86
+ end
87
+
88
+ def query_duration_class(ms)
89
+ return '' unless ms
90
+
91
+ if ms > 100
92
+ 'very-slow'
93
+ elsif ms > 50
94
+ 'slow'
95
+ elsif ms > 10
96
+ 'moderate'
97
+ else
98
+ ''
99
+ end
100
+ end
101
+
102
+ def status_class(status)
103
+ case status
104
+ when 200..299 then 'success'
105
+ when 300..399 then 'redirect'
106
+ when 400..499 then 'client-error'
107
+ when 500..599 then 'server-error'
108
+ else ''
109
+ end
110
+ end
111
+
112
+ def log_level_class(level)
113
+ case level.to_s.downcase
114
+ when 'error', 'fatal' then 'error'
115
+ when 'warn', 'warning' then 'warning'
116
+ when 'info' then 'info'
117
+ when 'debug' then 'debug'
118
+ else ''
119
+ end
120
+ end
121
+
122
+ def format_timestamp(time)
123
+ return '' unless time
124
+
125
+ time.strftime('%H:%M:%S.%L')
126
+ end
127
+
128
+ def memory_class(delta_mb)
129
+ return '' unless delta_mb
130
+
131
+ if delta_mb > 50
132
+ 'high'
133
+ elsif delta_mb > 20
134
+ 'moderate'
135
+ else
136
+ ''
137
+ end
138
+ end
139
+
140
+ # Cache compiled ERB template, reloading only if file changed (dev mode)
141
+ def cached_erb
142
+ current_mtime = begin
143
+ File.mtime(@template_path)
144
+ rescue StandardError
145
+ nil
146
+ end
147
+
148
+ if @cached_erb.nil? || (current_mtime && current_mtime != @template_mtime)
149
+ template = File.read(@template_path)
150
+ @cached_erb = ERB.new(template, trim_mode: '-')
151
+ @template_mtime = current_mtime
152
+ end
153
+
154
+ @cached_erb
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'cgi'
5
+
6
+ module BrainzLab
7
+ module DevTools
8
+ module Renderers
9
+ class ErrorPageRenderer
10
+ def initialize
11
+ @template_path = File.join(DevTools::ASSETS_PATH, 'templates', 'error_page.html.erb')
12
+ @cached_erb = nil
13
+ @template_mtime = nil
14
+ end
15
+
16
+ def render(exception, data)
17
+ erb = cached_erb
18
+
19
+ # Make data available to template
20
+ @exception = exception
21
+ @data = data
22
+ @backtrace = data[:backtrace] || []
23
+ @request = data[:request] || {}
24
+ @context = data[:context] || {}
25
+ @sql_queries = data[:sql_queries] || []
26
+ @environment = data[:environment] || {}
27
+ @source_extract = data[:source_extract]
28
+
29
+ erb.result(binding)
30
+ end
31
+
32
+ private
33
+
34
+ def h(text)
35
+ CGI.escapeHTML(text.to_s)
36
+ end
37
+
38
+ def asset_url(file)
39
+ "#{DevTools.asset_path}/#{file}"
40
+ end
41
+
42
+ def format_params(params, indent = 0)
43
+ return '' if params.nil? || params.empty?
44
+
45
+ lines = []
46
+ prefix = ' ' * indent
47
+
48
+ params.each do |key, value|
49
+ if value.is_a?(Hash)
50
+ lines << "#{prefix}#{h(key)}:"
51
+ lines << format_params(value, indent + 1)
52
+ elsif value.is_a?(Array)
53
+ lines << "#{prefix}#{h(key)}: #{h(value.inspect)}"
54
+ else
55
+ lines << "#{prefix}#{h(key)}: #{h(value)}"
56
+ end
57
+ end
58
+
59
+ lines.join("\n")
60
+ end
61
+
62
+ def truncate(text, length = 100)
63
+ return '' unless text
64
+
65
+ text.length > length ? "#{text[0...length]}..." : text
66
+ end
67
+
68
+ def time_ago(time)
69
+ return 'unknown' unless time
70
+
71
+ seconds = Time.now.utc - time
72
+ case seconds
73
+ when 0..59 then "#{seconds.to_i}s ago"
74
+ when 60..3599 then "#{(seconds / 60).to_i}m ago"
75
+ else "#{(seconds / 3600).to_i}h ago"
76
+ end
77
+ end
78
+
79
+ # Cache compiled ERB template, reloading only if file changed
80
+ def cached_erb
81
+ current_mtime = begin
82
+ File.mtime(@template_path)
83
+ rescue StandardError
84
+ nil
85
+ end
86
+
87
+ if @cached_erb.nil? || (current_mtime && current_mtime != @template_mtime)
88
+ template = File.read(@template_path)
89
+ @cached_erb = ERB.new(template, trim_mode: '-')
90
+ @template_mtime = current_mtime
91
+ end
92
+
93
+ @cached_erb
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'devtools/data/collector'
4
+ require_relative 'devtools/middleware/asset_server'
5
+ require_relative 'devtools/middleware/database_handler'
6
+ require_relative 'devtools/middleware/error_page'
7
+ require_relative 'devtools/middleware/debug_panel'
8
+ require_relative 'devtools/renderers/error_page_renderer'
9
+ require_relative 'devtools/renderers/debug_panel_renderer'
10
+
11
+ module BrainzLab
12
+ module DevTools
13
+ ASSETS_PATH = File.expand_path('devtools/assets', __dir__)
14
+
15
+ class << self
16
+ def enabled?
17
+ BrainzLab.configuration.devtools_enabled
18
+ end
19
+
20
+ def error_page_enabled?
21
+ enabled? && BrainzLab.configuration.devtools_error_page_enabled
22
+ end
23
+
24
+ def debug_panel_enabled?
25
+ enabled? && BrainzLab.configuration.devtools_debug_panel_enabled
26
+ end
27
+
28
+ def allowed_environment?
29
+ allowed = BrainzLab.configuration.devtools_allowed_environments
30
+ current = BrainzLab.configuration.environment
31
+ allowed.include?(current)
32
+ end
33
+
34
+ def allowed_ip?(request_ip)
35
+ # Skip IP checking in development - environment check is enough
36
+ return true if BrainzLab.configuration.environment == 'development'
37
+
38
+ return true if BrainzLab.configuration.devtools_allowed_ips.empty?
39
+
40
+ allowed_ips = BrainzLab.configuration.devtools_allowed_ips
41
+ return true if allowed_ips.include?(request_ip)
42
+
43
+ # Check CIDR ranges
44
+ allowed_ips.any? do |ip|
45
+ if ip.include?('/')
46
+ ip_in_cidr?(request_ip, ip)
47
+ else
48
+ ip == request_ip
49
+ end
50
+ end
51
+ end
52
+
53
+ def asset_path
54
+ BrainzLab.configuration.devtools_asset_path
55
+ end
56
+
57
+ def panel_position
58
+ BrainzLab.configuration.devtools_panel_position
59
+ end
60
+
61
+ def expand_by_default?
62
+ BrainzLab.configuration.devtools_expand_by_default
63
+ end
64
+
65
+ private
66
+
67
+ def ip_in_cidr?(ip, cidr)
68
+ require 'ipaddr'
69
+ IPAddr.new(cidr).include?(IPAddr.new(ip))
70
+ rescue IPAddr::InvalidAddressError
71
+ false
72
+ end
73
+ end
74
+ end
75
+ end