mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-08-28 17:42:50 +09:00
161 lines
4.4 KiB
Ruby
Executable File
161 lines
4.4 KiB
Ruby
Executable File
#!/usr/bin/env ruby
|
|
require "json"
|
|
require "csv"
|
|
|
|
BIN_MS = 10_000
|
|
SLO_P95_MS = 500
|
|
|
|
class Run
|
|
attr_reader :label, :dir
|
|
|
|
def initialize(label)
|
|
@label = label
|
|
@dir = File.expand_path("../tmp/bench/#{label}", __dir__)
|
|
abort "No results in tmp/bench/#{label}" unless File.exist?(File.join(dir, "k6.log"))
|
|
end
|
|
|
|
def report
|
|
write_series
|
|
print_summary
|
|
end
|
|
|
|
def series
|
|
@series ||= build_series
|
|
end
|
|
|
|
def meta
|
|
@meta ||= JSON.parse(File.read(File.join(dir, "meta.json")))
|
|
end
|
|
|
|
def summary
|
|
@summary ||= JSON.parse(File.read(File.join(dir, "summary.json")))
|
|
rescue Errno::ENOENT
|
|
{}
|
|
end
|
|
|
|
def capacity
|
|
breach = first_sustained_breach
|
|
if breach
|
|
breach[:connected]
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
private
|
|
def build_series
|
|
events = parse_k6_log
|
|
stats = parse_stats
|
|
first_ts = events.map { |e| e[:ts] }.min
|
|
return [] unless first_ts
|
|
|
|
bins = Hash.new { |h, k| h[k] = { lats: [], conns: 0, sents: 0, errs: 0, cpu: [], mem: [] } }
|
|
events.each do |event|
|
|
bin = (event[:ts] - first_ts) / BIN_MS
|
|
case event[:kind]
|
|
when "CONN" then bins[bin][:conns] += 1
|
|
when "LAT" then bins[bin][:lats] << event[:value]
|
|
when "SENT" then bins[bin][:sents] += 1
|
|
when "ERR" then bins[bin][:errs] += 1
|
|
end
|
|
end
|
|
stats.each do |stat|
|
|
bin = (stat[:ts] - first_ts) / BIN_MS
|
|
next if bin < 0
|
|
bins[bin][:cpu] << stat[:cpu]
|
|
bins[bin][:mem] << stat[:mem]
|
|
end
|
|
|
|
connected = 0
|
|
(0..bins.keys.max).map do |bin|
|
|
data = bins[bin]
|
|
connected = [ connected + data[:conns], meta["users"] ].min
|
|
{
|
|
elapsed_s: bin * BIN_MS / 1000,
|
|
connected: connected,
|
|
sent: data[:sents],
|
|
samples: data[:lats].size,
|
|
lat_p50: percentile(data[:lats], 50),
|
|
lat_p95: percentile(data[:lats], 95),
|
|
lat_max: data[:lats].max,
|
|
errors: data[:errs],
|
|
cpu_perc: average(data[:cpu]),
|
|
mem_mb: average(data[:mem])&.then { |bytes| (bytes / 1024.0 / 1024.0).round(1) }
|
|
}
|
|
end
|
|
end
|
|
|
|
def parse_k6_log
|
|
events = []
|
|
File.foreach(File.join(dir, "k6.log")) do |line|
|
|
if line =~ /BENCH (CONN|LAT|SENT|ERR) (\d+)(?: (\S+))?/
|
|
events << { kind: $1, ts: $2.to_i, value: $3.to_i }
|
|
end
|
|
end
|
|
events
|
|
end
|
|
|
|
def parse_stats
|
|
path = File.join(dir, "stats.csv")
|
|
return [] unless File.exist?(path)
|
|
CSV.read(path, headers: true).map do |row|
|
|
{ ts: row["epoch_ms"].to_i, cpu: row["cpu_perc"].to_f, mem: row["mem_bytes"].to_i }
|
|
end
|
|
end
|
|
|
|
def percentile(values, pct)
|
|
return nil if values.empty?
|
|
sorted = values.sort
|
|
sorted[((pct / 100.0) * (sorted.size - 1)).round]
|
|
end
|
|
|
|
def average(values)
|
|
return nil if values.empty?
|
|
(values.sum / values.size.to_f).round(1)
|
|
end
|
|
|
|
def write_series
|
|
CSV.open(File.join(dir, "series.csv"), "w") do |csv|
|
|
csv << series.first.keys
|
|
series.each { |row| csv << row.values }
|
|
end
|
|
end
|
|
|
|
def first_sustained_breach
|
|
series.each_cons(2) do |a, b|
|
|
if breached?(a) && breached?(b)
|
|
return a
|
|
end
|
|
end
|
|
nil
|
|
end
|
|
|
|
def breached?(bin)
|
|
if bin[:samples] > 0 && bin[:lat_p95] && bin[:lat_p95] > SLO_P95_MS
|
|
true
|
|
else
|
|
bin[:errors] > 0
|
|
end
|
|
end
|
|
|
|
def print_summary
|
|
latency = summary.dig("metrics", "delivery_latency") || {}
|
|
puts
|
|
puts "== #{label} (#{meta['cpus']} CPU, #{meta['memory']} RAM, ramp to #{meta['users']}) =="
|
|
puts " delivery latency: p50=#{latency['med']&.round(1)}ms p95=#{latency['p(95)']&.round(1)}ms max=#{latency['max']&.round(1)}ms"
|
|
puts " messages received: #{summary.dig('metrics', 'bench_messages_received', 'count')}"
|
|
puts " socket errors: #{summary.dig('metrics', 'bench_socket_errors', 'count') || 0}"
|
|
peak = series.map { |bin| bin[:mem_mb] }.compact.max
|
|
puts " peak container memory: #{peak} MB"
|
|
if capacity
|
|
puts " capacity at SLO (p95 <= #{SLO_P95_MS}ms, no errors): ~#{capacity} concurrent users"
|
|
else
|
|
puts " capacity at SLO: not reached (>= #{series.last[:connected]} concurrent users)"
|
|
end
|
|
puts " series: tmp/bench/#{label}/series.csv"
|
|
end
|
|
end
|
|
|
|
abort "Usage: bin/bench-report LABEL [LABEL...]" if ARGV.empty?
|
|
ARGV.each { |label| Run.new(label).report }
|