hey.rb 1.0.0

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.
data/exe/hey ADDED
@@ -0,0 +1,405 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Copyright 2014 Google Inc. All Rights Reserved.
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ # Translated from hey.go. Command hey is an HTTP load generator.
19
+
20
+ require "etc"
21
+ require "uri"
22
+
23
+ require "hey"
24
+
25
+ module Hey
26
+ module CLI
27
+ HEADER_REGEXP = '^([\w-]+):\s*(.+)'
28
+ AUTH_REGEXP = '^(.+):([^\s].+)'
29
+ HEY_UA = "hey/0.0.1"
30
+
31
+ # Go's math.MaxInt32.
32
+ MAX_INT32 = 2_147_483_647
33
+
34
+ # A stand-in for Go's flag package, covering the forms it accepts for the
35
+ # flags declared below: -name value, -name=value, --name value, and a bare
36
+ # -name for booleans. Parsing stops at the first non-flag argument.
37
+ FLAGS = {
38
+ "m" => [:string, "GET"],
39
+ "h" => [:string, ""],
40
+ "d" => [:string, ""],
41
+ "D" => [:string, ""],
42
+ "A" => [:string, ""],
43
+ "T" => [:string, "text/html"],
44
+ "a" => [:string, ""],
45
+ "host" => [:string, ""],
46
+ "U" => [:string, ""],
47
+
48
+ "o" => [:string, ""],
49
+
50
+ "c" => [:int, 50],
51
+ "n" => [:int, 200],
52
+ "q" => [:float, 0.0],
53
+ "t" => [:int, 20],
54
+ "z" => [:duration, 0.0],
55
+
56
+ "h2" => [:bool, false],
57
+ "cpus" => [:int, Etc.nprocessors],
58
+
59
+ "disable-compression" => [:bool, false],
60
+ "disable-keepalive" => [:bool, false],
61
+ "disable-redirects" => [:bool, false],
62
+ "x" => [:string, ""],
63
+
64
+ # Go: flag.Var(&hs, "H", "") -- a repeatable flag.
65
+ "H" => [:strings, []]
66
+ }.freeze
67
+
68
+ USAGE = <<~'TEXT'
69
+ Usage: hey [options...] <url>
70
+
71
+ Options:
72
+ -n Number of requests to run. Default is 200.
73
+ -c Number of workers to run concurrently. Total number of requests cannot
74
+ be smaller than the concurrency level. Default is 50.
75
+ -q Rate limit, in queries per second (QPS) per worker. Default is no rate limit.
76
+ -z Duration of application to send requests. When duration is reached,
77
+ application stops and exits. If duration is specified, n is ignored.
78
+ Examples: -z 10s -z 3m.
79
+ -o Output type. If none provided, a summary is printed.
80
+ "csv" is the only supported alternative. Dumps the response
81
+ metrics in comma-separated values format.
82
+
83
+ -m HTTP method, one of GET, POST, PUT, DELETE, HEAD, OPTIONS.
84
+ -H Custom HTTP header. You can specify as many as needed by repeating the flag.
85
+ For example, -H "Accept: text/html" -H "Content-Type: application/xml" .
86
+ -t Timeout for each request in seconds. Default is 20, use 0 for infinite.
87
+ -A HTTP Accept header.
88
+ -d HTTP request body.
89
+ -D HTTP request body from file. For example, /home/user/file.txt or ./file.txt.
90
+ -T Content-type, defaults to "text/html".
91
+ -U User-Agent, defaults to version "hey/0.0.1".
92
+ -a Basic authentication, username:password.
93
+ -x HTTP Proxy address as host:port.
94
+ -h2 Enable HTTP/2.
95
+
96
+ -host HTTP Host header.
97
+
98
+ -disable-compression Disable compression.
99
+ -disable-keepalive Disable keep-alive, prevents re-use of TCP
100
+ connections between different HTTP requests.
101
+ -disable-redirects Disable following of HTTP redirects
102
+ -cpus Number of used cpu cores.
103
+ (default for current machine is %d cores)
104
+ TEXT
105
+
106
+ # Units accepted by Go's time.ParseDuration.
107
+ DURATION_UNITS = {
108
+ "ns" => 1e-9,
109
+ "us" => 1e-6,
110
+ "µs" => 1e-6,
111
+ "ms" => 1e-3,
112
+ "s" => 1.0,
113
+ "m" => 60.0,
114
+ "h" => 3600.0
115
+ }.freeze
116
+
117
+ class << self
118
+ def usage_text
119
+ format(USAGE, Etc.nprocessors)
120
+ end
121
+
122
+ # Go: flag.Usage
123
+ def print_usage
124
+ $stderr.print(usage_text)
125
+ end
126
+
127
+ def err_and_exit(msg)
128
+ $stderr.printf("%s", msg)
129
+ $stderr.printf("\n")
130
+ exit 1
131
+ end
132
+
133
+ def usage_and_exit(msg)
134
+ unless msg.nil? || msg.empty?
135
+ $stderr.printf("%s", msg)
136
+ $stderr.printf("\n\n")
137
+ end
138
+ print_usage
139
+ $stderr.printf("\n")
140
+ exit 1
141
+ end
142
+
143
+ def parse_input_with_regexp(input, regx)
144
+ re = Regexp.new(regx)
145
+ matches = re.match(input)
146
+ # Go returns the full match followed by the groups; the array is
147
+ # shaped the same way here so the indices line up.
148
+ raise ArgumentError, "could not parse the provided input; input = #{input}" if matches.nil?
149
+
150
+ [matches[0], matches[1], matches[2]]
151
+ end
152
+
153
+ # Go: time.ParseDuration
154
+ def parse_duration(value)
155
+ raise ArgumentError, "time: invalid duration #{value.inspect}" if value.nil? || value.empty?
156
+
157
+ rest = value
158
+ sign = 1.0
159
+ if rest.start_with?("-", "+")
160
+ sign = -1.0 if rest.start_with?("-")
161
+ rest = rest[1..]
162
+ end
163
+ return 0.0 if rest == "0"
164
+ raise ArgumentError, "time: invalid duration #{value.inspect}" if rest.empty?
165
+
166
+ total = 0.0
167
+ until rest.empty?
168
+ m = /\A(\d+(?:\.\d*)?|\.\d+)(ns|us|µs|ms|s|m|h)/.match(rest)
169
+ raise ArgumentError, "time: invalid duration #{value.inspect}" if m.nil?
170
+
171
+ total += m[1].to_f * DURATION_UNITS.fetch(m[2])
172
+ rest = m.post_match
173
+ end
174
+
175
+ sign * total
176
+ end
177
+
178
+ # Go's flag package accepts these spellings for a bool flag's value.
179
+ def parse_bool(value)
180
+ case value
181
+ when "1", "t", "T", "true", "TRUE", "True" then true
182
+ when "0", "f", "F", "false", "FALSE", "False" then false
183
+ else raise ArgumentError, "invalid boolean value #{value.inspect}"
184
+ end
185
+ end
186
+
187
+ def coerce(name, type, value)
188
+ case type
189
+ when :string then value
190
+ when :int then Integer(value, 10)
191
+ when :float then Float(value)
192
+ when :duration then parse_duration(value)
193
+ when :bool then parse_bool(value)
194
+ end
195
+ rescue ArgumentError, TypeError => e
196
+ usage_and_exit("invalid value #{value.inspect} for flag -#{name}: #{e.message}")
197
+ end
198
+
199
+ # Go: flag.Parse(). Returns [values, remaining args].
200
+ def parse_flags(argv)
201
+ values = {}
202
+ # dup so the repeatable -H array is not shared between parses.
203
+ FLAGS.each { |name, (_type, default)| values[name] = default.dup }
204
+
205
+ args = argv.dup
206
+ until args.empty?
207
+ arg = args.first
208
+ break unless arg.start_with?("-") && arg.length > 1
209
+
210
+ args.shift
211
+ break if arg == "--"
212
+
213
+ body = arg.sub(/\A--?/, "")
214
+ name, eq, inline = body.partition("=")
215
+
216
+ spec = FLAGS[name]
217
+ usage_and_exit("flag provided but not defined: -#{name}") if spec.nil?
218
+
219
+ type, = spec
220
+
221
+ if type == :bool
222
+ # Go only accepts -name=false, never -name false.
223
+ value = eq.empty? ? "true" : inline
224
+ elsif eq.empty?
225
+ usage_and_exit("flag needs an argument: -#{name}") if args.empty?
226
+ value = args.shift
227
+ else
228
+ value = inline
229
+ end
230
+
231
+ if type == :strings
232
+ values[name] << value
233
+ else
234
+ values[name] = coerce(name, type, value)
235
+ end
236
+ end
237
+
238
+ [values, args]
239
+ end
240
+
241
+ def main(argv = ARGV)
242
+ flags, args = parse_flags(argv)
243
+ usage_and_exit("") if args.length < 1
244
+
245
+ hs = flags["H"]
246
+
247
+ # Go: runtime.GOMAXPROCS(*cpus). Ruby's async runs on a single thread
248
+ # per process, so the flag is accepted and has no effect.
249
+ num = flags["n"]
250
+ conc = flags["c"]
251
+ q = flags["q"]
252
+ dur = flags["z"]
253
+
254
+ if dur > 0
255
+ num = MAX_INT32
256
+ usage_and_exit("-c cannot be smaller than 1.") if conc <= 0
257
+ else
258
+ usage_and_exit("-n and -c cannot be smaller than 1.") if num <= 0 || conc <= 0
259
+
260
+ usage_and_exit("-n cannot be less than -c.") if num < conc
261
+ end
262
+
263
+ url = args[0]
264
+ method = flags["m"].upcase
265
+
266
+ # set content-type
267
+ header = ::Protocol::HTTP::Headers.new
268
+ header.set("content-type", flags["T"])
269
+ # set any other additional headers
270
+ usage_and_exit("Flag '-h' is deprecated, please use '-H' instead.") unless flags["h"].empty?
271
+ # set any other additional repeatable headers
272
+ hs.each do |h|
273
+ begin
274
+ match = parse_input_with_regexp(h, HEADER_REGEXP)
275
+ rescue ArgumentError => e
276
+ usage_and_exit(e.message)
277
+ end
278
+ header.set(match[1], match[2])
279
+ end
280
+
281
+ header.set("accept", flags["A"]) unless flags["A"].empty?
282
+
283
+ # set basic auth if set
284
+ username = ""
285
+ password = ""
286
+ unless flags["a"].empty?
287
+ begin
288
+ match = parse_input_with_regexp(flags["a"], AUTH_REGEXP)
289
+ rescue ArgumentError => e
290
+ usage_and_exit(e.message)
291
+ end
292
+ username = match[1]
293
+ password = match[2]
294
+ end
295
+
296
+ body_all = nil
297
+ body_all = flags["d"].b unless flags["d"].empty?
298
+ unless flags["D"].empty?
299
+ begin
300
+ slurp = File.binread(flags["D"])
301
+ rescue SystemCallError => e
302
+ err_and_exit(e.message)
303
+ end
304
+ body_all = slurp
305
+ end
306
+
307
+ proxy_url = nil
308
+ unless flags["x"].empty?
309
+ begin
310
+ proxy_url = ::URI.parse(flags["x"])
311
+ rescue ::URI::Error => e
312
+ usage_and_exit(e.message)
313
+ end
314
+ end
315
+
316
+ begin
317
+ req = Requester::Request.new(method, url)
318
+ rescue ArgumentError => e
319
+ usage_and_exit(e.message)
320
+ end
321
+ req.content_length = body_all ? body_all.bytesize : 0
322
+ req.set_basic_auth(username, password) if !username.empty? || !password.empty?
323
+
324
+ # set host header if set
325
+ req.host = flags["host"] unless flags["host"].empty?
326
+
327
+ ua = header["user-agent"]
328
+ if ua.nil? || ua.empty?
329
+ ua = HEY_UA
330
+ else
331
+ ua += " #{HEY_UA}"
332
+ end
333
+ header.set("user-agent", ua)
334
+
335
+ # set userAgent header if set
336
+ unless flags["U"].empty?
337
+ ua = "#{flags['U']} #{HEY_UA}"
338
+ header.set("user-agent", ua)
339
+ end
340
+
341
+ # NOTE: this mirrors hey.go exactly, including an upstream bug: the
342
+ # assignment replaces the header set built above, discarding the
343
+ # Authorization header that SetBasicAuth wrote a few lines earlier. So
344
+ # -a has no effect, in Go and here alike.
345
+ req.headers = header
346
+
347
+ w = Requester::Work.new(
348
+ request: req,
349
+ request_body: body_all,
350
+ n: num,
351
+ c: conc,
352
+ qps: q,
353
+ timeout: flags["t"],
354
+ disable_compression: flags["disable-compression"],
355
+ disable_keep_alives: flags["disable-keepalive"],
356
+ disable_redirects: flags["disable-redirects"],
357
+ h2: flags["h2"],
358
+ proxy_addr: proxy_url,
359
+ output: flags["o"]
360
+ )
361
+ w.init
362
+
363
+ # Go: signal.Notify(c, os.Interrupt) plus a goroutine per stop source.
364
+ # A self-pipe carries the signal into the reactor, because a Ruby trap
365
+ # handler cannot touch async primitives directly.
366
+ interrupt_r, interrupt_w = IO.pipe
367
+ previous = Signal.trap("INT") do
368
+ begin
369
+ interrupt_w.write_nonblock("x")
370
+ rescue StandardError
371
+ nil
372
+ end
373
+ end
374
+
375
+ Async do |task|
376
+ stopper = task.async do
377
+ interrupt_r.wait_readable
378
+ w.stop
379
+ end
380
+
381
+ timer =
382
+ if dur > 0
383
+ task.async do
384
+ sleep(dur)
385
+ w.stop
386
+ end
387
+ end
388
+
389
+ begin
390
+ w.perform
391
+ ensure
392
+ stopper.stop
393
+ timer&.stop
394
+ end
395
+ end
396
+ ensure
397
+ Signal.trap("INT", previous) if previous
398
+ interrupt_r&.close
399
+ interrupt_w&.close
400
+ end
401
+ end
402
+ end
403
+ end
404
+
405
+ Hey::CLI.main if $PROGRAM_NAME == __FILE__
@@ -0,0 +1,29 @@
1
+ # Copyright 2018 Google Inc. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # Translated from requester/now_other.go (build tag: !windows).
16
+
17
+ module Hey
18
+ module Requester
19
+ # Process start, captured once, against which every measurement is taken.
20
+ START_TIME = Process.clock_gettime(Process::CLOCK_MONOTONIC)
21
+
22
+ # now returns the elapsed duration, in seconds, using the stdlib monotonic
23
+ # clock. The Go original returns a time.Duration; durations are plain
24
+ # Floats of seconds throughout this port.
25
+ def self.now
26
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - START_TIME
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,46 @@
1
+ # Copyright 2018 Google Inc. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # Translated from requester/now_windows.go.
16
+ #
17
+ # The Go original reaches into kernel32.dll for QueryPerformanceCounter and
18
+ # QueryPerformanceFrequency, because Go's time.Now() on Windows used to have
19
+ # only millisecond resolution -- far too coarse for per-request latencies.
20
+ #
21
+ # CRuby does not have that problem: on Windows, Process::CLOCK_MONOTONIC is
22
+ # itself implemented on top of QueryPerformanceCounter, scaled by
23
+ # QueryPerformanceFrequency. So the correct translation of the Go file is to
24
+ # use the monotonic clock directly rather than to re-bind the DLL through
25
+ # Fiddle. The frequency is read here only so the resolution is reportable.
26
+
27
+ module Hey
28
+ module Requester
29
+ # Ticks per second of the underlying performance counter.
30
+ QPC_FREQUENCY = begin
31
+ resolution = Process.clock_getres(Process::CLOCK_MONOTONIC)
32
+ resolution.positive? ? (1.0 / resolution).round : 0
33
+ rescue StandardError
34
+ 0
35
+ end
36
+
37
+ # Process start, captured once, against which every measurement is taken.
38
+ START_TIME = Process.clock_gettime(Process::CLOCK_MONOTONIC)
39
+
40
+ # now returns the elapsed duration, in seconds, using the
41
+ # QueryPerformanceCounter-backed monotonic clock.
42
+ def self.now
43
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - START_TIME
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,151 @@
1
+ # Copyright 2014 Google Inc. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # Translated from requester/print.go.
16
+ #
17
+ # Hey supports two output formats: summary and CSV
18
+ #
19
+ # The summary output presents a number of statistics about the requests in a
20
+ # human-readable format, including:
21
+ # - general statistics: requests/second, total runtime, and average, fastest,
22
+ # and slowest requests.
23
+ # - a response time histogram.
24
+ # - a percentile latency distribution.
25
+ # - statistics (average, fastest, slowest) on the stages of the requests.
26
+ #
27
+ # The comma-separated CSV format is proceeded by a header, and consists of the
28
+ # following columns:
29
+ # 1. response-time: Total time taken for request (in seconds)
30
+ # 2. DNS+dialup: Time taken to establish the TCP connection (in seconds)
31
+ # 3. DNS: Time taken to do the DNS lookup (in seconds)
32
+ # 4. Request-write: Time taken to write full request (in seconds)
33
+ # 5. Response-delay: Time taken to first byte received (in seconds)
34
+ # 6. Response-read: Time taken to read full response (in seconds)
35
+ # 7. status-code: HTTP status code of the response (e.g. 200)
36
+ # 8. offset: The time since the start of the benchmark when the
37
+ # request was started. (in seconds)
38
+
39
+ require "erb"
40
+ require "json"
41
+
42
+ module Hey
43
+ module Requester
44
+ # Go's text/template becomes ERB here. The two built-in templates below are
45
+ # transliterated from the Go originals, whitespace included, so the output
46
+ # is byte-identical; ERB happens to treat the newline after a `<% %>` tag
47
+ # the same way Go templates do for this layout.
48
+ #
49
+ # As in Go, an -o value other than "" or "csv" is itself treated as a
50
+ # user-supplied template.
51
+ def self.new_template(output)
52
+ output_tmpl =
53
+ case output
54
+ when nil, "" then DEFAULT_TMPL
55
+ when "csv" then CSV_TMPL
56
+ else output
57
+ end
58
+
59
+ ERB.new(output_tmpl)
60
+ end
61
+
62
+ # The helper functions exposed to templates; the Go original registers
63
+ # these in a template.FuncMap.
64
+ module TemplateFunctions
65
+ def jsonify(value)
66
+ JSON.generate(value)
67
+ rescue JSON::GeneratorError
68
+ value.to_json
69
+ end
70
+
71
+ def format_number(duration)
72
+ format("%4.4f", duration)
73
+ end
74
+
75
+ def format_number_int(duration)
76
+ format("%d", duration)
77
+ end
78
+
79
+ def histogram(buckets)
80
+ max = 0
81
+ buckets.each do |b|
82
+ v = b.count
83
+ max = v if v > max
84
+ end
85
+
86
+ res = +""
87
+ buckets.each_index do |i|
88
+ # Normalize bar lengths.
89
+ bar_len = 0
90
+ bar_len = (buckets[i].count * 40 + max / 2) / max if max > 0
91
+ res << format(" %4.3f [%s]\t|%s\n", buckets[i].mark, buckets[i].count,
92
+ BAR_CHAR * bar_len)
93
+ end
94
+ res
95
+ end
96
+ end
97
+
98
+ # The object a template is evaluated against. It exposes the report
99
+ # snapshot as `r` (Go's dot) alongside the helper functions.
100
+ class TemplateContext
101
+ include TemplateFunctions
102
+
103
+ attr_reader :r
104
+
105
+ def initialize(report)
106
+ @r = report
107
+ end
108
+
109
+ def render(template)
110
+ template.result(binding)
111
+ end
112
+ end
113
+
114
+ DEFAULT_TMPL = <<~'TMPL'
115
+
116
+ Summary:
117
+ Total: <%= format_number(r.total) %> secs
118
+ Slowest: <%= format_number(r.slowest) %> secs
119
+ Fastest: <%= format_number(r.fastest) %> secs
120
+ Average: <%= format_number(r.average) %> secs
121
+ Requests/sec: <%= format_number(r.rps) %>
122
+ <% if r.size_total > 0 %>
123
+ Total data: <%= r.size_total %> bytes
124
+ Size/request: <%= r.size_req %> bytes<% end %>
125
+
126
+ Response time histogram:
127
+ <%= histogram(r.histogram) %>
128
+
129
+ Latency distribution:<% r.latency_distribution.each do |d| %>
130
+ <%= d.percentage %>%% in <%= format_number(d.latency) %> secs<% end %>
131
+
132
+ Details (average, fastest, slowest):
133
+ DNS+dialup: <%= format_number(r.avg_conn) %> secs, <%= format_number(r.conn_max) %> secs, <%= format_number(r.conn_min) %> secs
134
+ DNS-lookup: <%= format_number(r.avg_dns) %> secs, <%= format_number(r.dns_max) %> secs, <%= format_number(r.dns_min) %> secs
135
+ req write: <%= format_number(r.avg_req) %> secs, <%= format_number(r.req_max) %> secs, <%= format_number(r.req_min) %> secs
136
+ resp wait: <%= format_number(r.avg_delay) %> secs, <%= format_number(r.delay_max) %> secs, <%= format_number(r.delay_min) %> secs
137
+ resp read: <%= format_number(r.avg_res) %> secs, <%= format_number(r.res_max) %> secs, <%= format_number(r.res_min) %> secs
138
+
139
+ Status code distribution:<% r.status_code_dist.sort.each do |code, num| %>
140
+ [<%= code %>] <%= num %> responses<% end %>
141
+
142
+ <% if r.error_dist.length > 0 %>Error distribution:<% r.error_dist.sort.each do |err, num| %>
143
+ [<%= num %>] <%= err %><% end %><% end %>
144
+ TMPL
145
+
146
+ CSV_TMPL = <<~'TMPL'.chomp
147
+ response-time,DNS+dialup,DNS,Request-write,Response-delay,Response-read,status-code,offset<% r.lats.each_index do |i| %>
148
+ <%= format_number(r.lats[i]) %>,<%= format_number(r.conn_lats[i]) %>,<%= format_number(r.dns_lats[i]) %>,<%= format_number(r.req_lats[i]) %>,<%= format_number(r.delay_lats[i]) %>,<%= format_number(r.res_lats[i]) %>,<%= format_number_int(r.status_codes[i]) %>,<%= format_number(r.offsets[i]) %><% end %>
149
+ TMPL
150
+ end
151
+ end