lib/jmeter_perf/report/summary.rb in jmeter_perf-1.0.0 vs lib/jmeter_perf/report/summary.rb in jmeter_perf-1.0.1

- old
+ new

@@ -1,6 +1,7 @@ require "csv" +require "timeout" require_relative "../helpers/running_statistics" module JmeterPerf module Report # Summary provides a statistical analysis of performance test results by processing @@ -41,10 +42,12 @@ attr_reader :total_latency # @return [Integer] the total number of requests processed attr_reader :total_requests # @return [Integer] the total number of bytes sent attr_reader :total_sent_bytes + # @return [Array<Integer>] the line numbers of where CSV errors were encountered + attr_reader :csv_error_lines alias_method :rpm, :requests_per_minute alias_method :std, :standard_deviation alias_method :median, :p50 @@ -67,10 +70,28 @@ Latency IdleTime Connect ] + CSV_HEADER_MAPPINGS = { + "Average Response Time" => :@avg, + "Error Percentage" => :@error_percentage, + "Max Response Time" => :@max, + "Min Response Time" => :@min, + "10th Percentile" => :@p10, + "Median (50th Percentile)" => :@p50, + "95th Percentile" => :@p95, + "Requests Per Minute" => :@requests_per_minute, + "Standard Deviation" => :@standard_deviation, + "Total Bytes" => :@total_bytes, + "Total Elapsed Time" => :@total_elapsed_time, + "Total Errors" => :@total_errors, + "Total Latency" => :@total_latency, + "Total Requests" => :@total_requests, + "Total Sent Bytes" => :@total_sent_bytes + } + # Initializes a new Summary instance for analyzing performance data. # # @param file_path [String] the file path of the JTL file to analyze # @param name [String, nil] an optional name for the summary, derived from the file path if not provided (default: nil) def initialize(file_path, name = nil) @@ -85,37 +106,72 @@ @total_elapsed_time = 0 @total_errors = 0 @total_latency = 0 @total_requests = 0 @total_sent_bytes = 0 + @csv_error_lines = [] @file_path = file_path end # Marks the summary as finished, allowing any pending asynchronous operations to complete. # # @return [void] def finish! @finished = true + @processing_jtl_thread.join if @processing_jtl_thread&.alive? end + # Reads the generated CSV report and sets all appropriate attributes. + # + # @param csv_file [String] the file path of the CSV report to read + # @return [void] + def read_csv_report(csv_file) + CSV.foreach(csv_file, headers: true) do |row| + metric = row["Metric"] + value = row["Value"] + + if CSV_HEADER_MAPPINGS.key?(metric) + instance_variable_set(CSV_HEADER_MAPPINGS[metric], value.to_f) + elsif metric.start_with?("Response Code") + code = metric.split.last + @response_codes[code] = value.to_i + elsif metric == "CSV Errors" + @csv_error_lines = value.split(":") + end + end + end + + def generate_csv_report(output_file) + CSV.open(output_file, "wb") do |csv| + csv << ["Metric", "Value"] + CSV_HEADER_MAPPINGS.each do |metric, value| + csv << [metric, instance_variable_get(value)] + end + @response_codes.each do |code, count| + csv << ["Response Code #{code}", count] + end + + csv << ["CSV Errors", @csv_error_lines.join(":")] + end + end + # Starts streaming and processing JTL file content asynchronously. # # @return [Thread] a thread that handles the asynchronous file streaming and parsing def stream_jtl_async - Thread.new do + @processing_jtl_thread = Thread.new do sleep 0.1 until File.exist?(@file_path) # Wait for the file to be created File.open(@file_path, "r") do |file| file.seek(0, IO::SEEK_END) - until file.eof? && @finished + until @finished && file.eof? line = file.gets - if line - parse_csv_row(line) - else - sleep 0.1 # Small delay to avoid busy waiting - end + next unless line # Skip if no line was read + + # Process only if the line is complete (ends with a newline) + read_until_complete_line(file, line) end end end end @@ -124,26 +180,37 @@ # @return [void] def summarize_data! @p10, @p50, @p95 = @running_statistics_helper.get_percentiles(0.1, 0.5, 0.95) @error_percentage = (@total_errors.to_f / @total_requests) * 100 @avg = @running_statistics_helper.avg - @requests_per_minute = @total_elapsed_time.zero? ? 0 : @total_requests / (@total_elapsed_time / 1000) + @requests_per_minute = @total_elapsed_time.zero? ? 0 : (@total_requests / (@total_elapsed_time / 1000)) * 60 @standard_deviation = @running_statistics_helper.std end private - # Parses a single CSV row from the JTL file and updates running statistics. - # - # @param csv_row [String] a single line from the CSV-formatted JTL file - # @return [void] - def parse_csv_row(csv_row) - CSV.parse(csv_row, headers: JTL_HEADER).each do |row| + def read_until_complete_line(file, line, max_wait_seconds = 5) + return if file.lineno == 1 # Skip the header row + Timeout.timeout(max_wait_seconds) do + until line.end_with?("\n") + sleep 0.1 + line += file.gets.to_s + end + end + parse_csv_row(line) + rescue Timeout::Error + puts "Timeout waiting for line to complete: #{line}" + raise + rescue CSV::MalformedCSVError + @csv_error_lines << file.lineno + end + + def parse_csv_row(line) + CSV.parse(line, headers: JTL_HEADER, liberal_parsing: true).each do |row| line_item = row.to_hash elapsed = line_item.fetch(:elapsed).to_i @running_statistics_helper.add_number(elapsed) - @total_requests += 1 @total_elapsed_time += elapsed @response_codes[line_item.fetch(:responseCode)] += 1 @total_errors += (line_item.fetch(:success) == "true") ? 0 : 1 @total_bytes += line_item.fetch(:bytes, 0).to_i