require "csv" require "timeout" require_relative "../helpers/running_statistics" module JmeterPerf module Report # Summary provides a statistical analysis of performance test results by processing # JMeter JTL files. It calculates metrics such as average response time, error percentage, # min/max response times, and percentiles, helping to understand the distribution of # response times across requests. # @note This class uses a TDigest data structure to keep statistics "close enough". Accuracy is not guaranteed. # # @!attribute [rw] avg # @return [Float] the average response time # @!attribute [rw] error_percentage # @return [Float] the error percentage across all requests # @!attribute [rw] max # @return [Integer] the maximum response time encountered # @!attribute [rw] min # @return [Integer] the minimum response time encountered # @!attribute [rw] p10 # @return [Float] the 10th percentile of response times # @!attribute [rw] p50 # @return [Float] the median (50th percentile) of response times # @!attribute [rw] p95 # @return [Float] the 95th percentile of response times # @!attribute [rw] requests_per_minute # @return [Float] the requests per minute rate # @!attribute [rw] response_codes # @return [Hash<String, Integer>] a hash of response codes with their respective counts # @!attribute [rw] standard_deviation # @return [Float] the standard deviation of response times # @!attribute [rw] total_bytes # @return [Integer] the total number of bytes received # @!attribute [rw] total_errors # @return [Integer] the total number of errors encountered # @!attribute [rw] total_latency # @return [Integer] the total latency time across all requests # @!attribute [rw] total_requests # @return [Integer] the total number of requests processed # @!attribute [rw] total_sent_bytes # @return [Integer] the total number of bytes sent # @!attribute [rw] csv_error_lines # @return [Array<Integer>] the line numbers of where CSV errors were encountered # @!attribute [rw] total_run_time # @return [Integer] the total run time in seconds # @!attribute [rw] name # @return [String] the name of the summary, derived from the file path if not provided # @!attribute [rw] response_codes # @return [Hash<String, Integer>] a hash of response codes with their respective counts # @!attribute [rw] csv_error_lines # @return [Array<Integer>] the line numbers of where CSV errors were encountered class Summary # JTL file headers used for parsing CSV rows JTL_HEADER = %i[ timeStamp elapsed label responseCode responseMessage threadName dataType success failureMessage bytes sentBytes grpThreads allThreads URL Latency IdleTime Connect ] # @return [Hash<String, Symbol>] a mapping of CSV headers to their corresponding attribute symbols. CSV_HEADER_MAPPINGS = { "Name" => :name, "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 Run Time" => :total_run_time, "Total Bytes" => :total_bytes, "Total Errors" => :total_errors, "Total Latency" => :total_latency, "Total Requests" => :total_requests, "Total Sent Bytes" => :total_sent_bytes # "Response Code 200" => :response_codes["200"], # "Response Code 500" => :response_codes["500"] etc. } attr_accessor(*CSV_HEADER_MAPPINGS.values) # Response codes have multiple keys, so we need to handle them separately attr_accessor :response_codes # CSV Error Lines are an array of integers that get delimited by ":" when written to the CSV attr_accessor :csv_error_lines alias_method :rpm, :requests_per_minute alias_method :std, :standard_deviation alias_method :median, :p50 # Reads a generated CSV report and sets all appropriate attributes. # # @param csv_path [String] the file path of the CSV report to read # @return [Summary] a new Summary instance with the parsed data def self.read(csv_path) summary = new(file_path: csv_path) CSV.foreach(csv_path, headers: true) do |row| metric = row["Metric"] value = row["Value"] if metric == "Name" summary.name = value elsif metric.start_with?("Response Code") code = metric.split.last summary.response_codes[code] = value.to_i elsif metric == "CSV Errors" summary.csv_error_lines = value.split(":").map(&:to_i) elsif CSV_HEADER_MAPPINGS.key?(metric) summary.public_send(:"#{CSV_HEADER_MAPPINGS[metric]}=", value.include?(".") ? value.to_f : value.to_i) end end summary end # Initializes a new Summary instance for analyzing performance data. # # @param file_path [String] the file path of the performance file to summarize. Either a JTL or CSV file. # @param name [String, nil] an optional name for the summary, derived from the file path if not provided (default: nil) # @param jtl_read_timeout [Integer] the maximum number of seconds to wait for a line read (default: 3) def initialize(file_path:, name: nil, jtl_read_timeout: 30) @name = name || file_path.to_s.tr("/", "_") @jtl_read_timeout = jtl_read_timeout @finished = false @running_statistics_helper = JmeterPerf::Helpers::RunningStatistisc.new @max = 0 @min = 1_000_000 @response_codes = Hash.new { |h, k| h[k.to_s] = 0 } @total_bytes = 0 @total_errors = 0 @total_latency = 0 @total_requests = 0 @total_sent_bytes = 0 @csv_error_lines = [] @file_path = file_path @start_time = nil @end_time = nil end # Marks the summary as finished and joins the processing thread. # # @return [void] def finish! @finished = true @processing_jtl_thread&.join end # Generates a CSV report with the given output file. # # The CSV report includes the following: # - A header row with "Metric" and "Value". # - Rows for each metric and its corresponding value from `CSV_HEADER_MAPPINGS`. # - Rows for each response code and its count from `@response_codes`. # - A row for CSV errors, concatenated with ":". # # @param output_file [String] The path to the output CSV file. # @return [void] def write_csv(output_file) CSV.open(output_file, "wb") do |csv| csv << ["Metric", "Value"] CSV_HEADER_MAPPINGS.each do |metric, value| csv << [metric, public_send(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. # @note Once streaming, in order to finish processing, call `finish!` otherwise it will continue indefinitely. # @return [Thread] a thread that handles the asynchronous file streaming and parsing def stream_jtl_async @processing_jtl_thread = Thread.new do Timeout.timeout(@jtl_read_timeout) do sleep 0.1 until File.exist?(@file_path) # Wait for the file to be created end File.open(@file_path, "r") do |file| until @finished && file.eof? line = nil # Protect against blocking reads that are still in progress Timeout.timeout(@jtl_read_timeout) do line = file.gets sleep 0.1 until line || file.eof? || @finished end # Skip if the line is nil. Could be EOF but not yet marked as finished or vice versa. next if line.nil? # Process only if the line is complete (ends with a newline) read_until_complete_line(file, line) end end end @processing_jtl_thread.abort_on_exception = true @processing_jtl_thread end # Summarizes the collected data by calculating statistical metrics and error rates. # # @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 @total_run_time = ((@end_time - @start_time) / 1000).to_f # Convert milliseconds to seconds @requests_per_minute = @total_run_time.zero? ? 0 : (@total_requests / @total_run_time) * 60.0 @standard_deviation = @running_statistics_helper.std end private def read_until_complete_line(file, line) return if file.lineno == 1 # Skip the header row Timeout.timeout(@jtl_read_timeout) do until line.end_with?("\n") sleep 0.1 line += file.gets.to_s end end parse_csv_row(line) rescue Timeout::Error raise Timeout::Error, "Timed out reading JTL file at line #{file.lineno}" 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 timestamp = line_item.fetch(:timeStamp).to_i # Update start and end times @start_time = timestamp if @start_time.nil? || timestamp < @start_time @end_time = timestamp + elapsed if @end_time.nil? || (timestamp + elapsed) > @end_time # Continue with processing the row as before... @running_statistics_helper.add_number(elapsed) @total_requests += 1 @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 @total_sent_bytes += line_item.fetch(:sentBytes, 0).to_i @total_latency += line_item.fetch(:Latency).to_i @min = [@min, elapsed].min @max = [@max, elapsed].max end end end end end