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,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module DevTools
5
+ module Data
6
+ class Collector
7
+ THREAD_KEY = :brainzlab_devtools_data
8
+
9
+ class << self
10
+ def start_request(env)
11
+ Thread.current[THREAD_KEY] = {
12
+ started_at: Time.now.utc,
13
+ sql_queries: [],
14
+ views: [],
15
+ logs: [],
16
+ memory_before: get_memory_usage,
17
+ env: env
18
+ }
19
+
20
+ subscribe_to_events
21
+ end
22
+
23
+ def end_request
24
+ unsubscribe_from_events
25
+ data = Thread.current[THREAD_KEY]
26
+ Thread.current[THREAD_KEY] = nil
27
+ data
28
+ end
29
+
30
+ def active?
31
+ !Thread.current[THREAD_KEY].nil?
32
+ end
33
+
34
+ def get_request_data
35
+ data = Thread.current[THREAD_KEY] || {}
36
+ return {} if data.empty?
37
+
38
+ context = defined?(BrainzLab::Context) ? BrainzLab::Context.current : nil
39
+ duration_ms = data[:started_at] ? ((Time.now.utc - data[:started_at]) * 1000).round(2) : 0
40
+
41
+ {
42
+ timing: {
43
+ started_at: data[:started_at],
44
+ duration_ms: duration_ms
45
+ },
46
+ request: build_request_data(data, context),
47
+ controller: build_controller_data(context),
48
+ database: build_database_data(data[:sql_queries] || []),
49
+ views: build_views_data(data[:views] || []),
50
+ logs: data[:logs] || [],
51
+ memory: build_memory_data(data),
52
+ user: context&.user,
53
+ breadcrumbs: context&.breadcrumbs.to_a
54
+ }
55
+ end
56
+
57
+ def add_sql_query(name:, duration:, sql:, cached: false, source: nil)
58
+ data = Thread.current[THREAD_KEY]
59
+ return unless data
60
+
61
+ data[:sql_queries] << {
62
+ name: name,
63
+ duration: duration.round(2),
64
+ sql: sql,
65
+ sql_pattern: normalize_sql(sql),
66
+ cached: cached,
67
+ source: source,
68
+ timestamp: Time.now.utc
69
+ }
70
+ end
71
+
72
+ def add_view(type:, template:, duration:, layout: nil)
73
+ data = Thread.current[THREAD_KEY]
74
+ return unless data
75
+
76
+ data[:views] << {
77
+ type: type,
78
+ template: template,
79
+ duration: duration.round(2),
80
+ layout: layout,
81
+ timestamp: Time.now.utc
82
+ }
83
+ end
84
+
85
+ def add_log(level:, message:, log_data: nil)
86
+ request_data = Thread.current[THREAD_KEY]
87
+ return unless request_data
88
+
89
+ request_data[:logs] << {
90
+ level: level,
91
+ message: message,
92
+ data: log_data,
93
+ timestamp: Time.now.utc
94
+ }
95
+ end
96
+
97
+ private
98
+
99
+ def build_request_data(data, context)
100
+ env = data[:env] || {}
101
+ env['action_dispatch.request'] || (defined?(ActionDispatch::Request) ? ActionDispatch::Request.new(env) : nil)
102
+
103
+ {
104
+ method: context&.request_method || env['REQUEST_METHOD'],
105
+ path: context&.request_path || env['PATH_INFO'],
106
+ url: context&.request_url || env['REQUEST_URI'],
107
+ params: context&.request_params || {},
108
+ headers: extract_headers(env),
109
+ request_id: context&.request_id || env['action_dispatch.request_id']
110
+ }
111
+ end
112
+
113
+ def build_controller_data(context)
114
+ {
115
+ name: context&.controller,
116
+ action: context&.action
117
+ }
118
+ end
119
+
120
+ def build_database_data(queries)
121
+ {
122
+ queries: queries,
123
+ total_count: queries.length,
124
+ cached_count: queries.count { |q| q[:cached] },
125
+ total_duration_ms: queries.sum { |q| q[:duration] || 0 }.round(2),
126
+ n_plus_ones: detect_n_plus_ones(queries)
127
+ }
128
+ end
129
+
130
+ def build_views_data(views)
131
+ {
132
+ templates: views,
133
+ total_count: views.length,
134
+ total_duration_ms: views.sum { |v| v[:duration] || 0 }.round(2)
135
+ }
136
+ end
137
+
138
+ def build_memory_data(data)
139
+ current_memory = get_memory_usage
140
+ before_memory = data[:memory_before] || 0
141
+
142
+ {
143
+ before_mb: before_memory,
144
+ after_mb: current_memory,
145
+ delta_mb: (current_memory - before_memory).round(2)
146
+ }
147
+ end
148
+
149
+ def extract_headers(env)
150
+ headers = {}
151
+ env.each do |key, value|
152
+ if key.start_with?('HTTP_')
153
+ header_name = key.sub('HTTP_', '').split('_').map(&:capitalize).join('-')
154
+ headers[header_name] = value
155
+ end
156
+ end
157
+ headers
158
+ end
159
+
160
+ def subscribe_to_events
161
+ return unless defined?(ActiveSupport::Notifications)
162
+
163
+ @sql_subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |*args|
164
+ event = ActiveSupport::Notifications::Event.new(*args)
165
+ next if event.payload[:name] == 'SCHEMA'
166
+ next if event.payload[:sql]&.start_with?('PRAGMA')
167
+
168
+ add_sql_query(
169
+ name: event.payload[:name],
170
+ duration: event.duration,
171
+ sql: event.payload[:sql],
172
+ cached: event.payload[:cached] || event.payload[:name] == 'CACHE',
173
+ source: extract_source(caller)
174
+ )
175
+ end
176
+
177
+ @view_subscriber = ActiveSupport::Notifications.subscribe(/render_.+\.action_view/) do |*args|
178
+ event = ActiveSupport::Notifications::Event.new(*args)
179
+ type = event.name.include?('partial') ? :partial : :template
180
+
181
+ add_view(
182
+ type: type,
183
+ template: event.payload[:identifier],
184
+ duration: event.duration,
185
+ layout: event.payload[:layout]
186
+ )
187
+ end
188
+ end
189
+
190
+ def unsubscribe_from_events
191
+ return unless defined?(ActiveSupport::Notifications)
192
+
193
+ ActiveSupport::Notifications.unsubscribe(@sql_subscriber) if @sql_subscriber
194
+ ActiveSupport::Notifications.unsubscribe(@view_subscriber) if @view_subscriber
195
+ @sql_subscriber = nil
196
+ @view_subscriber = nil
197
+ end
198
+
199
+ def detect_n_plus_ones(queries)
200
+ non_cached = queries.reject { |q| q[:cached] }
201
+ pattern_groups = non_cached.group_by { |q| q[:sql_pattern] }
202
+
203
+ pattern_groups.select { |_, qs| qs.size >= 3 }.map do |pattern, matching|
204
+ {
205
+ pattern: pattern,
206
+ count: matching.size,
207
+ total_duration_ms: matching.sum { |q| q[:duration] || 0 }.round(2),
208
+ sample_query: matching.first[:sql],
209
+ source: matching.first[:source]
210
+ }
211
+ end
212
+ end
213
+
214
+ def normalize_sql(sql)
215
+ return nil unless sql
216
+
217
+ sql.gsub(/\b\d+\b/, '?')
218
+ .gsub(/'[^']*'/, '?')
219
+ .gsub(/\$\d+/, '?')
220
+ .gsub(%r{/\*.*?\*/}, '')
221
+ .gsub(/\s+/, ' ')
222
+ .strip
223
+ end
224
+
225
+ def get_memory_usage
226
+ `ps -o rss= -p #{Process.pid}`.to_i / 1024.0
227
+ rescue StandardError
228
+ 0
229
+ end
230
+
231
+ def extract_source(backtrace)
232
+ backtrace.each do |line|
233
+ next if line.include?('/brainzlab')
234
+ next if line.include?('/gems/')
235
+ next if line.include?('/ruby/')
236
+
237
+ if line.include?('/app/')
238
+ match = line.match(%r{(app/[^:]+:\d+)})
239
+ return match[1] if match
240
+ end
241
+ end
242
+ nil
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module DevTools
5
+ module Middleware
6
+ class AssetServer
7
+ MIME_TYPES = {
8
+ '.css' => 'text/css; charset=utf-8',
9
+ '.js' => 'application/javascript; charset=utf-8',
10
+ '.svg' => 'image/svg+xml',
11
+ '.png' => 'image/png',
12
+ '.woff2' => 'font/woff2'
13
+ }.freeze
14
+
15
+ def initialize(app)
16
+ @app = app
17
+ end
18
+
19
+ def call(env)
20
+ return @app.call(env) unless DevTools.enabled?
21
+
22
+ path = env['PATH_INFO']
23
+ asset_prefix = DevTools.asset_path
24
+
25
+ if path.start_with?("#{asset_prefix}/")
26
+ serve_asset(path.sub("#{asset_prefix}/", ''))
27
+ else
28
+ @app.call(env)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def serve_asset(relative_path)
35
+ # Prevent directory traversal
36
+ return not_found if relative_path.include?('..')
37
+
38
+ file_path = File.join(DevTools::ASSETS_PATH, relative_path)
39
+ return not_found unless File.exist?(file_path)
40
+
41
+ ext = File.extname(relative_path)
42
+ content_type = MIME_TYPES[ext] || 'application/octet-stream'
43
+ content = File.read(file_path)
44
+
45
+ [
46
+ 200,
47
+ {
48
+ 'Content-Type' => content_type,
49
+ 'Content-Length' => content.bytesize.to_s,
50
+ 'Cache-Control' => 'public, max-age=31536000',
51
+ 'X-Content-Type-Options' => 'nosniff'
52
+ },
53
+ [content]
54
+ ]
55
+ end
56
+
57
+ def not_found
58
+ [404, { 'Content-Type' => 'text/plain' }, ['Not Found']]
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module DevTools
5
+ module Middleware
6
+ class DatabaseHandler
7
+ ENDPOINT = '/_brainzlab/devtools/database'
8
+
9
+ def initialize(app)
10
+ @app = app
11
+ end
12
+
13
+ def call(env)
14
+ return @app.call(env) unless should_handle?(env)
15
+
16
+ handle_database_request(env)
17
+ end
18
+
19
+ private
20
+
21
+ def should_handle?(env)
22
+ return false unless DevTools.enabled?
23
+ return false unless DevTools.allowed_environment?
24
+ return false unless DevTools.allowed_ip?(extract_ip(env))
25
+ return false unless env['PATH_INFO'] == ENDPOINT
26
+ return false unless env['REQUEST_METHOD'] == 'POST'
27
+
28
+ true
29
+ end
30
+
31
+ def extract_ip(env)
32
+ forwarded = env['HTTP_X_FORWARDED_FOR']
33
+ return forwarded.split(',').first.strip if forwarded
34
+
35
+ env['REMOTE_ADDR']
36
+ end
37
+
38
+ def handle_database_request(env)
39
+ body = env['rack.input'].read
40
+ env['rack.input'].rewind
41
+ params = JSON.parse(body)
42
+ action = params['action']
43
+
44
+ result = case action
45
+ when 'migrate'
46
+ run_migrations
47
+ when 'status'
48
+ migration_status
49
+ when 'create'
50
+ create_database
51
+ when 'rollback'
52
+ rollback_migration
53
+ else
54
+ { success: false, output: "Unknown action: #{action}" }
55
+ end
56
+
57
+ json_response(result)
58
+ rescue StandardError => e
59
+ json_response({ success: false, output: "Error: #{e.message}\n\n#{e.backtrace&.first(10)&.join("\n")}" })
60
+ end
61
+
62
+ def run_migrations
63
+ return not_available('Rails') unless defined?(::Rails)
64
+
65
+ output = capture_output do
66
+ ActiveRecord::MigrationContext.new(
67
+ ::Rails.root.join('db/migrate'),
68
+ ActiveRecord::SchemaMigration
69
+ ).migrate
70
+ end
71
+
72
+ { success: true, output: output.presence || 'All migrations completed successfully!' }
73
+ rescue StandardError => e
74
+ { success: false, output: "Migration failed:\n#{e.message}\n\n#{e.backtrace&.first(10)&.join("\n")}" }
75
+ end
76
+
77
+ def migration_status
78
+ return not_available('Rails') unless defined?(::Rails)
79
+
80
+ output = StringIO.new
81
+
82
+ context = ActiveRecord::MigrationContext.new(
83
+ ::Rails.root.join('db/migrate'),
84
+ ActiveRecord::SchemaMigration
85
+ )
86
+
87
+ migrated = context.get_all_versions.to_set
88
+ migrations = context.migrations
89
+
90
+ output.puts "database: #{ActiveRecord::Base.connection_db_config.database}"
91
+ output.puts ''
92
+ output.puts ' Status Migration ID Migration Name'
93
+ output.puts '-' * 60
94
+
95
+ migrations.each do |migration|
96
+ status = migrated.include?(migration.version) ? ' up' : ' down'
97
+ output.puts " #{status} #{migration.version} #{migration.name}"
98
+ end
99
+
100
+ pending = migrations.reject { |m| migrated.include?(m.version) }
101
+ output.puts ''
102
+ if pending.any?
103
+ output.puts "#{pending.count} pending migration(s)"
104
+ else
105
+ output.puts 'All migrations are up to date!'
106
+ end
107
+
108
+ { success: true, output: output.string }
109
+ rescue StandardError => e
110
+ { success: false, output: "Failed to check status:\n#{e.message}" }
111
+ end
112
+
113
+ def create_database
114
+ return not_available('Rails') unless defined?(::Rails)
115
+
116
+ output = capture_output do
117
+ ActiveRecord::Tasks::DatabaseTasks.create_current
118
+ end
119
+
120
+ { success: true, output: output.presence || 'Database created successfully!' }
121
+ rescue ActiveRecord::DatabaseAlreadyExists
122
+ { success: true, output: 'Database already exists.' }
123
+ rescue StandardError => e
124
+ { success: false, output: "Failed to create database:\n#{e.message}" }
125
+ end
126
+
127
+ def rollback_migration
128
+ return not_available('Rails') unless defined?(::Rails)
129
+
130
+ output = capture_output do
131
+ ActiveRecord::MigrationContext.new(
132
+ ::Rails.root.join('db/migrate'),
133
+ ActiveRecord::SchemaMigration
134
+ ).rollback
135
+ end
136
+
137
+ { success: true, output: output.presence || 'Rollback completed!' }
138
+ rescue StandardError => e
139
+ { success: false, output: "Rollback failed:\n#{e.message}" }
140
+ end
141
+
142
+ def capture_output
143
+ original_stdout = $stdout
144
+ original_stderr = $stderr
145
+ captured = StringIO.new
146
+ $stdout = captured
147
+ $stderr = captured
148
+
149
+ yield
150
+
151
+ captured.string
152
+ ensure
153
+ $stdout = original_stdout
154
+ $stderr = original_stderr
155
+ end
156
+
157
+ def not_available(framework)
158
+ { success: false, output: "#{framework} is not available." }
159
+ end
160
+
161
+ def json_response(data)
162
+ body = JSON.generate(data)
163
+ [
164
+ 200,
165
+ {
166
+ 'Content-Type' => 'application/json; charset=utf-8',
167
+ 'Content-Length' => body.bytesize.to_s,
168
+ 'Cache-Control' => 'no-store',
169
+ 'X-Content-Type-Options' => 'nosniff'
170
+ },
171
+ [body]
172
+ ]
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module DevTools
5
+ module Middleware
6
+ class DebugPanel
7
+ HTML_CONTENT_TYPE = 'text/html'
8
+
9
+ def initialize(app)
10
+ @app = app
11
+ @renderer = Renderers::DebugPanelRenderer.new
12
+ end
13
+
14
+ def call(env)
15
+ return @app.call(env) unless should_inject?(env)
16
+
17
+ # Start collecting data
18
+ Data::Collector.start_request(env)
19
+
20
+ begin
21
+ status, headers, body = @app.call(env)
22
+
23
+ # Only inject into HTML responses
24
+ if injectable_response?(status, headers)
25
+ body = inject_panel(body, env, status, headers)
26
+ headers = update_content_length(headers, body)
27
+ end
28
+
29
+ [status, headers, body]
30
+ ensure
31
+ Data::Collector.end_request
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def should_inject?(env)
38
+ return false unless DevTools.debug_panel_enabled?
39
+ return false unless DevTools.allowed_environment?
40
+ return false unless DevTools.allowed_ip?(extract_ip(env))
41
+ return false if asset_request?(env['PATH_INFO'])
42
+ return false if devtools_asset_request?(env['PATH_INFO'])
43
+ return false if turbo_stream_request?(env)
44
+
45
+ true
46
+ end
47
+
48
+ def extract_ip(env)
49
+ forwarded = env['HTTP_X_FORWARDED_FOR']
50
+ return forwarded.split(',').first.strip if forwarded
51
+
52
+ env['REMOTE_ADDR']
53
+ end
54
+
55
+ def injectable_response?(status, headers)
56
+ return false unless status == 200
57
+
58
+ content_type = headers['Content-Type']
59
+ return false unless content_type
60
+
61
+ content_type.include?(HTML_CONTENT_TYPE)
62
+ end
63
+
64
+ def asset_request?(path)
65
+ return true if path.nil?
66
+
67
+ asset_paths = %w[/assets /packs /vite]
68
+ asset_extensions = %w[.js .css .map .png .jpg .jpeg .gif .svg .ico .woff .woff2 .ttf .eot]
69
+
70
+ asset_paths.any? { |p| path.start_with?(p) } ||
71
+ asset_extensions.any? { |ext| path.end_with?(ext) }
72
+ end
73
+
74
+ def devtools_asset_request?(path)
75
+ return false if path.nil?
76
+
77
+ path.start_with?(DevTools.asset_path)
78
+ end
79
+
80
+ def turbo_stream_request?(env)
81
+ accept = env['HTTP_ACCEPT'] || ''
82
+ accept.include?('text/vnd.turbo-stream.html')
83
+ end
84
+
85
+ def inject_panel(body, _env, status, headers)
86
+ # Collect all response body parts
87
+ full_body = collect_body(body)
88
+
89
+ # Get collected data
90
+ data = Data::Collector.get_request_data
91
+ data[:response] = {
92
+ status: status,
93
+ headers: headers.to_h,
94
+ content_type: headers['Content-Type']
95
+ }
96
+
97
+ # Render panel HTML
98
+ panel_html = @renderer.render(data)
99
+
100
+ # Inject before </body>
101
+ full_body = if full_body.include?('</body>')
102
+ full_body.sub('</body>', "#{panel_html}</body>")
103
+ else
104
+ # If no </body> tag, append at the end
105
+ "#{full_body}#{panel_html}"
106
+ end
107
+
108
+ [full_body]
109
+ end
110
+
111
+ def collect_body(body)
112
+ full_body = +''
113
+ body.each { |part| full_body << part }
114
+ body.close if body.respond_to?(:close)
115
+ full_body
116
+ end
117
+
118
+ def update_content_length(headers, body)
119
+ headers = headers.to_h.dup
120
+ headers['Content-Length'] = body.sum(&:bytesize).to_s
121
+ headers
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end