brainzlab 0.1.1 → 0.1.2

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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -0
  3. data/lib/brainzlab/beacon/client.rb +209 -0
  4. data/lib/brainzlab/beacon/provisioner.rb +44 -0
  5. data/lib/brainzlab/beacon.rb +215 -0
  6. data/lib/brainzlab/configuration.rb +341 -3
  7. data/lib/brainzlab/cortex/cache.rb +59 -0
  8. data/lib/brainzlab/cortex/client.rb +141 -0
  9. data/lib/brainzlab/cortex/provisioner.rb +49 -0
  10. data/lib/brainzlab/cortex.rb +227 -0
  11. data/lib/brainzlab/dendrite/client.rb +232 -0
  12. data/lib/brainzlab/dendrite/provisioner.rb +44 -0
  13. data/lib/brainzlab/dendrite.rb +195 -0
  14. data/lib/brainzlab/devtools/assets/devtools.css +1106 -0
  15. data/lib/brainzlab/devtools/assets/devtools.js +322 -0
  16. data/lib/brainzlab/devtools/assets/logo.svg +6 -0
  17. data/lib/brainzlab/devtools/assets/templates/debug_panel.html.erb +500 -0
  18. data/lib/brainzlab/devtools/assets/templates/error_page.html.erb +1086 -0
  19. data/lib/brainzlab/devtools/data/collector.rb +248 -0
  20. data/lib/brainzlab/devtools/middleware/asset_server.rb +63 -0
  21. data/lib/brainzlab/devtools/middleware/database_handler.rb +180 -0
  22. data/lib/brainzlab/devtools/middleware/debug_panel.rb +126 -0
  23. data/lib/brainzlab/devtools/middleware/error_page.rb +376 -0
  24. data/lib/brainzlab/devtools/renderers/debug_panel_renderer.rb +155 -0
  25. data/lib/brainzlab/devtools/renderers/error_page_renderer.rb +94 -0
  26. data/lib/brainzlab/devtools.rb +75 -0
  27. data/lib/brainzlab/flux/buffer.rb +96 -0
  28. data/lib/brainzlab/flux/client.rb +70 -0
  29. data/lib/brainzlab/flux/provisioner.rb +57 -0
  30. data/lib/brainzlab/flux.rb +174 -0
  31. data/lib/brainzlab/instrumentation/active_record.rb +18 -1
  32. data/lib/brainzlab/instrumentation/aws.rb +179 -0
  33. data/lib/brainzlab/instrumentation/dalli.rb +108 -0
  34. data/lib/brainzlab/instrumentation/excon.rb +152 -0
  35. data/lib/brainzlab/instrumentation/good_job.rb +102 -0
  36. data/lib/brainzlab/instrumentation/resque.rb +115 -0
  37. data/lib/brainzlab/instrumentation/solid_queue.rb +198 -0
  38. data/lib/brainzlab/instrumentation/stripe.rb +164 -0
  39. data/lib/brainzlab/instrumentation/typhoeus.rb +104 -0
  40. data/lib/brainzlab/instrumentation.rb +72 -0
  41. data/lib/brainzlab/nerve/client.rb +217 -0
  42. data/lib/brainzlab/nerve/provisioner.rb +44 -0
  43. data/lib/brainzlab/nerve.rb +219 -0
  44. data/lib/brainzlab/pulse/instrumentation.rb +35 -2
  45. data/lib/brainzlab/pulse/propagation.rb +1 -1
  46. data/lib/brainzlab/pulse/tracer.rb +1 -1
  47. data/lib/brainzlab/pulse.rb +1 -1
  48. data/lib/brainzlab/rails/log_subscriber.rb +1 -2
  49. data/lib/brainzlab/rails/railtie.rb +36 -3
  50. data/lib/brainzlab/recall/provisioner.rb +17 -0
  51. data/lib/brainzlab/recall.rb +6 -1
  52. data/lib/brainzlab/reflex.rb +2 -2
  53. data/lib/brainzlab/sentinel/client.rb +218 -0
  54. data/lib/brainzlab/sentinel/provisioner.rb +44 -0
  55. data/lib/brainzlab/sentinel.rb +165 -0
  56. data/lib/brainzlab/signal/client.rb +62 -0
  57. data/lib/brainzlab/signal/provisioner.rb +55 -0
  58. data/lib/brainzlab/signal.rb +136 -0
  59. data/lib/brainzlab/synapse/client.rb +290 -0
  60. data/lib/brainzlab/synapse/provisioner.rb +44 -0
  61. data/lib/brainzlab/synapse.rb +270 -0
  62. data/lib/brainzlab/utilities/circuit_breaker.rb +265 -0
  63. data/lib/brainzlab/utilities/health_check.rb +296 -0
  64. data/lib/brainzlab/utilities/log_formatter.rb +256 -0
  65. data/lib/brainzlab/utilities/rate_limiter.rb +230 -0
  66. data/lib/brainzlab/utilities.rb +17 -0
  67. data/lib/brainzlab/vault/cache.rb +80 -0
  68. data/lib/brainzlab/vault/client.rb +198 -0
  69. data/lib/brainzlab/vault/provisioner.rb +49 -0
  70. data/lib/brainzlab/vault.rb +268 -0
  71. data/lib/brainzlab/version.rb +1 -1
  72. data/lib/brainzlab/vision/client.rb +128 -0
  73. data/lib/brainzlab/vision/provisioner.rb +136 -0
  74. data/lib/brainzlab/vision.rb +157 -0
  75. data/lib/brainzlab.rb +101 -0
  76. 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
+ request = 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,180 @@
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
+ begin
40
+ body = env["rack.input"].read
41
+ env["rack.input"].rewind
42
+ params = JSON.parse(body)
43
+ action = params["action"]
44
+
45
+ result = case action
46
+ when "migrate"
47
+ run_migrations
48
+ when "status"
49
+ migration_status
50
+ when "create"
51
+ create_database
52
+ when "rollback"
53
+ rollback_migration
54
+ else
55
+ { success: false, output: "Unknown action: #{action}" }
56
+ end
57
+
58
+ json_response(result)
59
+ rescue => e
60
+ json_response({ success: false, output: "Error: #{e.message}\n\n#{e.backtrace&.first(10)&.join("\n")}" })
61
+ end
62
+ end
63
+
64
+ def run_migrations
65
+ return not_available("Rails") unless defined?(Rails)
66
+
67
+ output = capture_output do
68
+ ActiveRecord::MigrationContext.new(
69
+ Rails.root.join("db/migrate"),
70
+ ActiveRecord::SchemaMigration
71
+ ).migrate
72
+ end
73
+
74
+ { success: true, output: output.presence || "All migrations completed successfully!" }
75
+ rescue => e
76
+ { success: false, output: "Migration failed:\n#{e.message}\n\n#{e.backtrace&.first(10)&.join("\n")}" }
77
+ end
78
+
79
+ def migration_status
80
+ return not_available("Rails") unless defined?(Rails)
81
+
82
+ output = StringIO.new
83
+
84
+ context = ActiveRecord::MigrationContext.new(
85
+ Rails.root.join("db/migrate"),
86
+ ActiveRecord::SchemaMigration
87
+ )
88
+
89
+ migrated = context.get_all_versions.to_set
90
+ migrations = context.migrations
91
+
92
+ output.puts "database: #{ActiveRecord::Base.connection_db_config.database}"
93
+ output.puts ""
94
+ output.puts " Status Migration ID Migration Name"
95
+ output.puts "-" * 60
96
+
97
+ migrations.each do |migration|
98
+ status = migrated.include?(migration.version) ? " up" : " down"
99
+ output.puts " #{status} #{migration.version} #{migration.name}"
100
+ end
101
+
102
+ pending = migrations.reject { |m| migrated.include?(m.version) }
103
+ if pending.any?
104
+ output.puts ""
105
+ output.puts "#{pending.count} pending migration(s)"
106
+ else
107
+ output.puts ""
108
+ output.puts "All migrations are up to date!"
109
+ end
110
+
111
+ { success: true, output: output.string }
112
+ rescue => e
113
+ { success: false, output: "Failed to check status:\n#{e.message}" }
114
+ end
115
+
116
+ def create_database
117
+ return not_available("Rails") unless defined?(Rails)
118
+
119
+ output = capture_output do
120
+ ActiveRecord::Tasks::DatabaseTasks.create_current
121
+ end
122
+
123
+ { success: true, output: output.presence || "Database created successfully!" }
124
+ rescue ActiveRecord::DatabaseAlreadyExists
125
+ { success: true, output: "Database already exists." }
126
+ rescue => e
127
+ { success: false, output: "Failed to create database:\n#{e.message}" }
128
+ end
129
+
130
+ def rollback_migration
131
+ return not_available("Rails") unless defined?(Rails)
132
+
133
+ output = capture_output do
134
+ ActiveRecord::MigrationContext.new(
135
+ Rails.root.join("db/migrate"),
136
+ ActiveRecord::SchemaMigration
137
+ ).rollback
138
+ end
139
+
140
+ { success: true, output: output.presence || "Rollback completed!" }
141
+ rescue => e
142
+ { success: false, output: "Rollback failed:\n#{e.message}" }
143
+ end
144
+
145
+ def capture_output
146
+ original_stdout = $stdout
147
+ original_stderr = $stderr
148
+ captured = StringIO.new
149
+ $stdout = captured
150
+ $stderr = captured
151
+
152
+ yield
153
+
154
+ captured.string
155
+ ensure
156
+ $stdout = original_stdout
157
+ $stderr = original_stderr
158
+ end
159
+
160
+ def not_available(framework)
161
+ { success: false, output: "#{framework} is not available." }
162
+ end
163
+
164
+ def json_response(data)
165
+ body = JSON.generate(data)
166
+ [
167
+ 200,
168
+ {
169
+ "Content-Type" => "application/json; charset=utf-8",
170
+ "Content-Length" => body.bytesize.to_s,
171
+ "Cache-Control" => "no-store",
172
+ "X-Content-Type-Options" => "nosniff"
173
+ },
174
+ [body]
175
+ ]
176
+ end
177
+ end
178
+ end
179
+ end
180
+ 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
+ if full_body.include?("</body>")
102
+ full_body = full_body.sub("</body>", "#{panel_html}</body>")
103
+ else
104
+ # If no </body> tag, append at the end
105
+ full_body = "#{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