diff --git a/.gitignore b/.gitignore index 050e0f0..15eabab 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ /sqlite/ .env.production -resource_usage.* +*.log +bit diff --git a/benchmark.cr b/benchmark.cr deleted file mode 100755 index aa46bcd..0000000 --- a/benchmark.cr +++ /dev/null @@ -1,257 +0,0 @@ -#!/usr/bin/env crystal - -require "http/client" -require "json" -require "file_utils" - -# Configuration variables -SERVER_URL = "http://localhost:4000" -API_URL = "#{SERVER_URL}/api/links" -API_KEY = "secure_api_key_1" -NUMBER_OF_REQUESTS = 100000 - -CONTAINER_NAME = "bit" -STATS_FILE = "resource_usage.txt" - -class ResourceMonitor - def initialize(@container_name : String) - @running = false - @stats = [] of {timestamp: Time, cpu: Float64, memory: Float64} - end - - def start - @running = true - @stats.clear - - # Initialize stats file with header - File.write(STATS_FILE, "Timestamp\tCPU(%)\tMemory(MiB)\n") - - spawn do - while @running - if stat = capture_stats - # Append each measurement directly to the file - File.open(STATS_FILE, "a") do |file| - file.puts "#{stat[:timestamp].to_unix}\t#{stat[:cpu]}\t#{stat[:memory]}" - end - @stats << stat - end - end - end - end - - def stop - @running = false - end - - def avg_stats - return {cpu: 0.0, memory: 0.0} if @stats.empty? - - total_cpu = 0.0 - total_memory = 0.0 - @stats.each do |stat| - total_cpu += stat[:cpu] - total_memory += stat[:memory] - end - - { - cpu: total_cpu / @stats.size, - memory: total_memory / @stats.size - } - end - - private def capture_stats - output = IO::Memory.new - process = Process.run( - "docker", ["stats", "--no-stream", "--format", "{{.CPUPerc}},{{.MemUsage}}", @container_name], - output: output - ) - - if process.success? - line = output.to_s.strip - parts = line.split(",") - if parts.size == 2 - cpu_part = parts[0].gsub("%", "").to_f - - # Extract the memory value properly by removing the "MiB" suffix - mem_string = parts[1].split.first - mem_part = mem_string.gsub(/[A-Za-z]+$/, "").to_f - - return {timestamp: Time.utc, cpu: cpu_part, memory: mem_part} - end - end - nil - end -end - -def check_dependencies - {"docker", "jq", "bombardier"}.each do |cmd| - process = Process.run("which", [cmd], output: Process::Redirect::Close) - unless process.success? - puts "Error: #{cmd} is not installed. Please install it to proceed." - exit(1) - end - end -end - -def setup_containers - puts "Setting up..." - - process = Process.run("docker", ["compose", "up", "-d"]) - unless process.success? - puts "Failed to start Docker containers." - exit(1) - end - - puts "Waiting for the application to be ready..." - until begin - HTTP::Client.get("#{SERVER_URL}/api/ping").success? - rescue - false - end - sleep 1.seconds - end - - puts "Seeding the database..." - process = Process.run("docker", ["compose", "exec", "app", "sh", "-c", "sqlite3 ./sqlite/data.db < ./db/seed.sql"]) - - unless process.success? - puts "Error on seeding database" - exit(1) - end - - puts "Checking seed results..." - until begin - HTTP::Client.get( - "#{API_URL}?limit=1", - headers: HTTP::Headers{"X-Api-Key" => API_KEY} - ).success? - rescue - false - end - sleep 2.seconds - end -end - -def run_benchmark - puts "Fetching all created links from /api/links..." - - response = HTTP::Client.get( - "#{API_URL}?limit=10000", - headers: HTTP::Headers{"X-Api-Key" => API_KEY} - ) - - sleep 2.seconds - unless response.success? - puts "Failed to fetch links. Status: #{response.status_code}" - exit(1) - end - - data = JSON.parse(response.body) - links = data["data"].as_a.map { |link| link["refer"].as_s } - - random_link = links.sample - puts "Selected link for benchmarking: #{random_link}" - - puts "Starting benchmark with Bombardier..." - - sleep 2.seconds - process = Process.new( - "bombardier", - ["-n", NUMBER_OF_REQUESTS.to_s, "-l", "--disableKeepAlives", random_link], - output: Process::Redirect::Inherit, - error: Process::Redirect::Inherit - ) - - status = process.wait - - if status.success? - puts "Benchmark completed successfully." - else - puts "Bombardier failed with error code: #{status.exit_code}" - exit(1) - end -end - -def analyze_resource_usage - puts "Analyzing resource usage..." - - sleep 2.seconds - if File.exists?(STATS_FILE) - lines = File.read_lines(STATS_FILE) - # Skip header - lines = lines[1..-1] if lines.size > 0 - - if lines.size > 0 - total_cpu = 0.0 - total_memory = 0.0 - peak_cpu = 0.0 - peak_memory = 0.0 - - lines.each do |line| - fields = line.split("\t") - if fields.size >= 3 - begin - cpu = fields[1].to_f - memory = fields[2].to_f - - total_cpu += cpu - total_memory += memory - - # Track peaks in a single pass - peak_cpu = cpu if cpu > peak_cpu - peak_memory = memory if memory > peak_memory - rescue - # Skip invalid lines - end - end - end - - avg_cpu = total_cpu / lines.size - avg_memory = total_memory / lines.size - - stats_summary = <<-STATS - **** Resource Usage Statistics **** - Measurements: #{lines.size} - Average CPU Usage: #{avg_cpu.round(2)}% - Average Memory Usage: #{avg_memory.round(2)} MiB - Peak CPU Usage: #{peak_cpu.round(2)}% - Peak Memory Usage: #{peak_memory.round(2)} MiB - - STATS - - File.open(STATS_FILE, "a") do |file| - file.puts "\n" + stats_summary - end - - puts File.read(STATS_FILE) - else - puts "No resource usage data collected." - end - else - puts "Resource usage file not found." - end -end - -def cleanup - Process.run("docker", ["compose", "down"]) - puts "Cleanup completed. Resource usage data saved in #{STATS_FILE}" -end - -def main - check_dependencies - setup_containers - - monitor = ResourceMonitor.new(CONTAINER_NAME) - monitor.start - - begin - run_benchmark - - monitor.stop - analyze_resource_usage - ensure - cleanup - end -end - -main diff --git a/scripts/benchmark.cr b/scripts/benchmark.cr new file mode 100644 index 0000000..d75900d --- /dev/null +++ b/scripts/benchmark.cr @@ -0,0 +1,279 @@ +#!/usr/bin/env crystal + +require "http/client" +require "json" + +SERVER_URL = "http://localhost:4000" +API_URL = "#{SERVER_URL}/api/links" +API_KEY = "secure_api_key_1" +NUMBER_OF_REQUESTS = 100000 + +APP_COMMAND = "./bit" +APP_ARGS = [] of String # Add any arguments if needed +STATS_FILE = "resource_usage.log" +APP_LOG_FILE = "app_output.log" + +class ResourceMonitor + def initialize(@pid : Int32) + @running = false + @stats = [] of {timestamp: Time, cpu: Float64, memory: Float64} + end + + def start + @running = true + @stats.clear + File.write(STATS_FILE, "Timestamp\tCPU(%)\tMemory(MiB)\n") + + spawn do + while @running + if stat = capture_stats + File.open(STATS_FILE, "a") do |file| + file.puts "#{stat[:timestamp].to_unix}\t#{stat[:cpu]}\t#{stat[:memory]}" + end + @stats << stat + end + sleep 1.seconds + end + end + end + + def stop + @running = false + sleep 1.seconds + end + + private def capture_stats + output = IO::Memory.new + process = Process.run( + "ps", ["-p", @pid.to_s, "-o", "%cpu,%mem,rss"], + output: output + ) + + if process.success? + lines = output.to_s.strip.split("\n") + if lines.size >= 2 + data_line = lines[1].strip.split + if data_line.size >= 3 + cpu = data_line[0].to_f + # RSS is in KB on macOS, convert to MiB + memory_kb = data_line[2].to_f + memory_mib = memory_kb / 1024.0 + + return {timestamp: Time.utc, cpu: cpu, memory: memory_mib} + end + end + end + nil + end + + def print_summary + return if @stats.empty? + + total_cpu = 0.0 + total_memory = 0.0 + peak_cpu = 0.0 + peak_memory = 0.0 + + @stats.each do |stat| + total_cpu += stat[:cpu] + total_memory += stat[:memory] + peak_cpu = stat[:cpu] if stat[:cpu] > peak_cpu + peak_memory = stat[:memory] if stat[:memory] > peak_memory + end + + avg_cpu = total_cpu / @stats.size + avg_memory = total_memory / @stats.size + + summary = <<-STATS + + **** Resource Usage Statistics **** + Measurements: #{@stats.size} + Average CPU Usage: #{avg_cpu.round(2)}% + Average Memory Usage: #{avg_memory.round(2)} MiB + Peak CPU Usage: #{peak_cpu.round(2)}% + Peak Memory Usage: #{peak_memory.round(2)} MiB + + STATS + + File.open(STATS_FILE, "a") do |file| + file.puts summary + end + + puts summary + end +end + +def start_application : Process + puts "Starting application: #{APP_COMMAND}..." + puts "Application output will be saved to: #{APP_LOG_FILE}" + + # Open log file for writing + log_file = File.open(APP_LOG_FILE, "w") + + process = Process.new( + APP_COMMAND, + APP_ARGS, + output: log_file, + error: log_file + ) + + puts "Application started with PID: #{process.pid}" + process +end + +def stop_application(process : Process) + puts "\nStopping application..." + process.signal(Signal::TERM) + + # Give it a few seconds to shut down gracefully + sleep 3.seconds + + # Force kill if still running + begin + process.signal(Signal::KILL) + rescue + # Process already terminated + end + + puts "Application stopped." +end + +def check_dependencies + {"bombardier", "sqlite3"}.each do |cmd| + process = Process.run("which", [cmd], output: Process::Redirect::Close) + unless process.success? + puts "Error: #{cmd} is not installed. Please install it to proceed." + case cmd + when "bombardier" + puts " brew install bombardier" + when "sqlite3" + puts " brew install sqlite3" + end + exit(1) + end + end +end + +def wait_for_server + puts "Checking if server is ready at #{SERVER_URL}..." + + 30.times do + begin + if HTTP::Client.get("#{SERVER_URL}/api/ping").success? + puts "Server is ready!" + return + end + rescue + # Server not ready yet + end + sleep 1.seconds + print "." + end + + puts "\nError: Server is not responding. Please start your application first." + exit(1) +end + +def run_benchmark + puts "Fetching links from API..." + + response = HTTP::Client.get( + "#{API_URL}?limit=10000", + headers: HTTP::Headers{"X-Api-Key" => API_KEY} + ) + + unless response.success? + puts "Failed to fetch links. Status: #{response.status_code}" + puts "Make sure your server is running and the API key is correct." + exit(1) + end + + data = JSON.parse(response.body) + links = data["data"].as_a.map { |link| link["refer"].as_s } + + if links.empty? + puts "No links found. Please seed your database first." + exit(1) + end + + random_link = links.sample + puts "Selected link: #{random_link}" + puts "\nStarting benchmark with #{NUMBER_OF_REQUESTS} requests..." + + sleep 2.seconds + + process = Process.new( + "bombardier", + ["-n", NUMBER_OF_REQUESTS.to_s, "-l", "--disableKeepAlives", random_link], + output: Process::Redirect::Inherit, + error: Process::Redirect::Inherit + ) + + status = process.wait + + if status.success? + puts "\nBenchmark completed successfully." + else + puts "\nBombardier failed with error code: #{status.exit_code}" + exit(1) + end +end + +def seed_database + puts "Seeding database..." + + unless File.exists?("./db/seed.sql") + puts "Warning: ./db/seed.sql not found. Skipping database seeding." + return + end + + unless File.exists?("./sqlite/data.db") + puts "Warning: ./sqlite/data.db not found. Database may not be initialized." + end + + process = Process.run( + "sqlite3", + ["./sqlite/data.db"], + input: File.open("./db/seed.sql"), + output: Process::Redirect::Inherit, + error: Process::Redirect::Inherit + ) + + if process.success? + puts "Database seeded successfully." + else + puts "Warning: Database seeding failed. Continuing anyway..." + end +end + +def main + check_dependencies + + app_process = start_application + + begin + wait_for_server + + seed_database + + # Give it a moment to settle + sleep 2.seconds + + monitor = ResourceMonitor.new(app_process.pid.to_i32) + monitor.start + + run_benchmark + + monitor.stop + monitor.print_summary + + puts "\n**** Files Generated ****" + puts " Resource stats: #{STATS_FILE}" + puts " Application log: #{APP_LOG_FILE}" + ensure + # Always stop the application + stop_application(app_process) + end +end + +main diff --git a/shard.lock b/shard.lock index 36d2245..218196b 100644 --- a/shard.lock +++ b/shard.lock @@ -2,11 +2,11 @@ version: 2.0 shards: backtracer: git: https://github.com/sija/backtracer.cr.git - version: 1.2.2 + version: 1.2.4 crecto: git: https://github.com/fridgerator/crecto.git - version: 0.12.1 + version: 0.14.0 db: git: https://github.com/crystal-lang/crystal-db.git @@ -18,7 +18,7 @@ shards: exception_page: git: https://github.com/crystal-loot/exception_page.git - version: 0.4.1 + version: 0.5.0 ipaddress: git: https://github.com/sija/ipaddress.cr.git @@ -26,7 +26,7 @@ shards: kemal: git: https://github.com/kemalcr/kemal.git - version: 1.5.0 + version: 1.7.3 maxminddb: git: https://github.com/delef/maxminddb.cr.git diff --git a/shard.yml b/shard.yml index 529cb82..4a59fd0 100644 --- a/shard.yml +++ b/shard.yml @@ -2,17 +2,20 @@ name: bit version: 1.5.3 authors: - - Juan Rodriguez + - Juan Rodriguez Ronado <@sjdonado> targets: bit: main: bit.cr cli: main: scripts/cli.cr + benchmark: + main: scripts/benchmark.cr dependencies: kemal: github: kemalcr/kemal + version: 1.7.3 sqlite3: github: crystal-lang/crystal-sqlite3 crecto: