log_stats 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 0f874fbbcc589b39b6cd4b7146fa8f32bd4a57f1
4
+ data.tar.gz: a9b0fab518d53e1bfe2718d3d2986277fc1a394b
5
+ SHA512:
6
+ metadata.gz: b420cce2a9ffc8001403ba1213db7681a43ef56eeb00a1549f259165d61ac30722f09ccc4642838b05cb6b6d137366afed954f245aa8f97411b61f2c034f5727
7
+ data.tar.gz: 1727b1363af36c20fdd66b4e4ac30e6d2c866d26430a1a0526ea10b278c922d0e496f9dc0a2b3bbd0c2d241659770298899ccba01d1730bce735fd52536cf5ec
data/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ vendor
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.3.1
5
+ before_install: gem install bundler -v 1.13.1
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in log_stats.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 Peter Marklund
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # Log Stats
2
+
3
+ A rubygem for extracting response time and error stats based on log files such as
4
+ the Heroku router log. Example usage with Heroku addon papertrail:
5
+
6
+ ```
7
+ gem install papertrail
8
+ PAPERTRAIL_API_TOKEN=... papertrail --min-time "yesterday 22:00" --max-time "yesterday 22:30" > tmp/papertrail.log
9
+ gem install log_stats
10
+ log_stats /tmp/papertrail.log
11
+ ```
12
+
13
+ To download longer timeperiods, like a whole day, download and gunzip a Papertrail log archive file.
14
+
15
+ Example Heroku success log line:
16
+
17
+ ```
18
+ 768004272804798492 2017-02-14T03:41:49 2017-02-14T03:41:49Z 505641143 cmore-web-prod 54.144.85.82 Local3 Info heroku/router at=info method=GET path="/filmer/med/glenn-erland-tosterud" host=www.cmore.se request_id=29cb0a66-23f9-4ef4-999a-65f8de089208 fwd="216.244.66.238,23.54.19.54" dyno=web.5 connect=0ms service=210ms status=200 bytes=52867
19
+ ```
20
+ Example Heroku error log line:
21
+
22
+ ```
23
+ 768277650920906757 2017-02-14T21:48:07 2017-02-14T21:48:08Z 505641143 cmore-web-prod 54.196.126.116 Local3 Info heroku/router at=error code=H12 desc="Request timeout" method=GET path="/serie/74763-alvinnn-og-gjengen-tv-serien/sesong-1/episode-2/3282212-alvinnn-og-gjengen-tv-serien-forelsket-i-rektor-norsk-tale" host=www.cmore.no request_id=f5c2921a-3974-4522-8fdf-d9bfff8b1db9 fwd="163.172.66.89,80.239.216.108" dyno=web.5 connect=0ms service=30001ms status=503 bytes=0
24
+ ```
25
+
26
+ ## License
27
+
28
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
29
+
30
+ ## Resources:
31
+
32
+ * Heroku Logging: https://devcenter.heroku.com/articles/logging
33
+ * Heroku Error Codes: https://devcenter.heroku.com/articles/error-codes
34
+ * Papertrail archives: https://papertrailapp.com/account/archives
35
+ * Papertrail archive download: http://help.papertrailapp.com/kb/how-it-works/permanent-log-archives/#downloading-multiple-archives
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList['test/**/*_test.rb']
8
+ end
9
+
10
+ task :default => :test
data/bin/log_stats ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'log_stats'
4
+ require "log_stats/config"
5
+
6
+ log_file_data = ARGF.read
7
+ config = LogStats::Config.default_config.merge(LogStats::Config.env_config)
8
+ LogStats.run(log_file_data, config)
@@ -0,0 +1,55 @@
1
+ module LogStats
2
+ module Config
3
+ def self.default_config
4
+ {
5
+ events: {
6
+ requests: {
7
+ # NOTE: matches Heroku router lines. Also matches Papertrails slightly modified lines.
8
+ line_pattern: /\s(heroku\/router)|(heroku\[router\]:)\s/,
9
+ fields: [
10
+ {
11
+ name: :time,
12
+ parse: Proc.new { |line| line[/\b20\d\d-\d\d-\d\dT\d\d:\d\d:\d\d/] }
13
+ },
14
+ {name: :method},
15
+ {name: :host},
16
+ {name: :path},
17
+ {name: :status, numeric: true},
18
+ {name: :code, optional: true},
19
+ {name: :service, numeric: true}
20
+ ],
21
+ top_list_limit: 100,
22
+ apdex: {tolerating: 500, frustrated: 2000},
23
+ apdex_goal: 0.9,
24
+ }
25
+ },
26
+ verbose: true,
27
+ stats_format: "text"
28
+ }
29
+ end
30
+
31
+ def self.env_config
32
+ default_config.keys.reduce({}) do |acc, key|
33
+ value = env_value(key)
34
+ if !value.nil?
35
+ acc[key] = value
36
+ end
37
+ acc
38
+ end
39
+ end
40
+
41
+ def self.env_value(key)
42
+ env_key = key.to_s.upcase
43
+ value = ENV[env_key]
44
+ if !value.nil? && boolean?(default_config[key])
45
+ ['1', true, 'true', 't', 'TRUE'].include?(value)
46
+ else
47
+ value
48
+ end
49
+ end
50
+
51
+ def self.boolean?(value)
52
+ !!value == value
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,52 @@
1
+ module LogStats
2
+ module LineParser
3
+ def self.parse(log_data, config)
4
+ data = {}
5
+ log_data.split("\n").each do |line_string|
6
+ config[:events].each do |event, event_config|
7
+ if event_config[:line_pattern] =~ line_string
8
+ data[event] ||= []
9
+ data[event].push(parse_line(line_string, event_config))
10
+ end
11
+ end
12
+ end
13
+ data
14
+ end
15
+
16
+ def self.strip_quotes(value)
17
+ if value && value.start_with?('"')
18
+ value[1..-2]
19
+ else
20
+ value
21
+ end
22
+ end
23
+
24
+ def self.parse_numeric(value)
25
+ value[/\d+/].to_i
26
+ end
27
+
28
+ def self.parse_field(field, line_string, event_config)
29
+ if field[:parse]
30
+ value = field[:parse].call(line_string)
31
+ else
32
+ value = /\b#{field[:name]}=(\S+)/.match(line_string).to_a[1]
33
+ value = strip_quotes(value)
34
+ value = parse_numeric(value) if field[:numeric]
35
+ end
36
+ puts "Parsing failed field=#{field} line=#{line_string}" unless (value || field[:optional])
37
+ value
38
+ rescue Exception => e
39
+ puts "Parsing failed field=#{field} line=#{line_string}: #{e.message}"
40
+ nil
41
+ end
42
+
43
+ def self.parse_line(line_string, event_config)
44
+ event_config[:fields].reduce({}) do |acc, field|
45
+ if value = parse_field(field, line_string, event_config)
46
+ acc[field[:name]] = value
47
+ end
48
+ acc
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,11 @@
1
+ module LogStats
2
+ class Logger
3
+ def initialize(verbose)
4
+ @verbose = verbose
5
+ end
6
+
7
+ def info(message)
8
+ puts(message) if @verbose
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,48 @@
1
+ module LogStats
2
+ module Requests
3
+ module KPI
4
+ PRECISION = 5
5
+
6
+ def self.calculate(requests, stats)
7
+ {
8
+ request_count: requests.size,
9
+ response_time_avg: avg(:service, requests),
10
+ response_time_95p: percentile(:service, 0.95, requests),
11
+ apdex: avg(Stats.method(:apdex_metric), stats),
12
+ error_rate: rate(method(:error?), requests),
13
+ timeout_rate: rate(method(:timeout?), requests)
14
+ }
15
+ end
16
+
17
+ def self.rate(predicate, requests)
18
+ (count(predicate, requests).to_f/requests.size).round(PRECISION)
19
+ end
20
+
21
+ def self.count(predicate, requests)
22
+ requests.select(&predicate).size
23
+ end
24
+
25
+ def self.avg(metric, requests)
26
+ sum = requests.reduce(0) do |acc, request|
27
+ value = metric.respond_to?(:call) ? metric.call(request) : request[metric]
28
+ acc + value
29
+ end
30
+ (sum/requests.size.to_f).round(PRECISION)
31
+ end
32
+
33
+ def self.percentile(field, percentile, requests)
34
+ index = (requests.size*percentile).round - 1
35
+ request = requests.sort_by { |request| request[field] }[index]
36
+ request && request[field]
37
+ end
38
+
39
+ def self.error?(request)
40
+ Stats.error_status?(request[:status])
41
+ end
42
+
43
+ def self.timeout?(request)
44
+ request[:code] == "H12"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,89 @@
1
+ module LogStats
2
+ module Requests
3
+ module Stats
4
+ def self.stats(requests, event_config)
5
+ requests.reduce({}) do |acc, request|
6
+ id = (request[:method] == "GET" ? '' : "#{request[:method]} ") + [request[:host], request[:path]].join('')
7
+ acc[id] ||= {
8
+ id: id,
9
+ method: request[:method],
10
+ path: request[:path],
11
+ count: 0,
12
+ success_count: 0,
13
+ error_count: 0,
14
+ timeout_count: 0,
15
+ code_count: Hash.new(0),
16
+ satisfied_count: 0,
17
+ tolerating_count: 0,
18
+ frustrated_count: 0,
19
+ service: 0
20
+ }
21
+ acc[id][:count] += 1
22
+ if error_status?(request[:status])
23
+ acc[id][:error_count] += 1
24
+ acc[id][:frustrated_count] += 1
25
+ else
26
+ acc[id][:success_count] += 1
27
+ if request[:service] <= event_config[:apdex][:tolerating]
28
+ acc[id][:satisfied_count] += 1
29
+ elsif request[:service] <= event_config[:apdex][:frustrated]
30
+ acc[id][:tolerating_count] += 1
31
+ else
32
+ acc[id][:frustrated_count] += 1
33
+ end
34
+ end
35
+ if request[:code]
36
+ acc[id][:code_count][request[:code]] += 1
37
+ end
38
+ if request[:code] == "H12"
39
+ acc[id][:timeout_count] += 1
40
+ end
41
+ acc[id][:service] += request[:service]
42
+ acc
43
+ end.values
44
+ end
45
+
46
+ def self.error_status?(status)
47
+ status / 100 == 5
48
+ end
49
+
50
+ def self.requests_by_duration(requests)
51
+ requests.sort_by { |request| -request[:service].to_i }
52
+ end
53
+
54
+ def self.duration_metric(stat)
55
+ stat[:service].to_f/stat[:count]
56
+ end
57
+
58
+ def self.error_rate_metric(stat)
59
+ stat[:error_count].to_f/stat[:count]
60
+ end
61
+
62
+ def self.timeout_metric(stat)
63
+ stat[:timeout_count]
64
+ end
65
+
66
+ def self.popularity_metric(stat)
67
+ stat[:count]
68
+ end
69
+
70
+ def self.apdex_metric(stat)
71
+ (stat[:satisfied_count] + stat[:tolerating_count].to_f/2)/stat[:count]
72
+ end
73
+
74
+ def self.stats_by_metric(stats, metric, direction = 1)
75
+ stats.sort do |stat1, stat2|
76
+ metric1 = -1 * direction * metric.call(stat1)
77
+ metric2 = -1 * direction * metric.call(stat2)
78
+ if metric1 == metric2
79
+ 0
80
+ elsif metric1.nil? || metric2.nil?
81
+ metric1.nil? ? -1 : 1
82
+ else
83
+ metric1 > metric2 ? 1 : -1
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,87 @@
1
+ module LogStats
2
+ module Requests
3
+ module TextOutput
4
+ def self.print(data, event_config)
5
+ print_heading("KPIs")
6
+ print_kpi(data[:kpi])
7
+
8
+ print_heading("STATUS CODES")
9
+ print_percentages(data[:requests_count], data[:requests_by_status])
10
+
11
+ print_heading("HEROKU ERROR CODES")
12
+ print_percentages(data[:requests_count], data[:requests_by_code])
13
+
14
+ top_list_options = {
15
+ direction: 1,
16
+ limit: event_config[:top_list_limit],
17
+ apdex_goal: event_config[:apdex_goal]
18
+ }
19
+
20
+ print_heading("POPULARITY TOP LIST")
21
+ print_top_list(data[:stats], Stats.method(:popularity_metric), top_list_options)
22
+
23
+ print_heading("APDEX TOP LIST")
24
+ print_top_list(data[:stats], Stats.method(:apdex_metric), top_list_options.merge(direction: -1))
25
+
26
+ print_heading("DURATION TOP LIST")
27
+ print_top_list(data[:stats], Stats.method(:duration_metric), top_list_options)
28
+
29
+ print_heading("ERROR RATE TOP LIST")
30
+ print_top_list(data[:stats], Stats.method(:error_rate_metric), top_list_options)
31
+
32
+ print_heading("TIMEOUT TOP LIST")
33
+ print_top_list(data[:stats], Stats.method(:timeout_metric), top_list_options)
34
+
35
+ data[:requests_by_status].each do |status, requests|
36
+ print_heading("REQUESTS - STATUS #{status}")
37
+ Stats.requests_by_duration(requests).each do |request|
38
+ print_request(request)
39
+ end
40
+ end
41
+ end
42
+
43
+ def self.print_request(request)
44
+ parts = [(request[:method] == "GET" ? nil : request[:method]),
45
+ request[:path],
46
+ request[:service],
47
+ request[:code]
48
+ ].compact
49
+ if parts.size > 1
50
+ puts parts.join(" ")
51
+ end
52
+ end
53
+
54
+ def self.print_percentages(total_lines_count, grouped_lines)
55
+ grouped_lines.select { |key, _| !key.nil? }.each do |key, lines|
56
+ percent = (lines.size.to_f*100/total_lines_count).round(4)
57
+ puts "#{key} #{percent}%"
58
+ end
59
+ end
60
+
61
+ def self.print_top_list(stats, metric, options = {})
62
+ Stats.stats_by_metric(stats, metric, options[:direction])[0, options[:limit]].each do |stat|
63
+ apdex = Stats.apdex_metric(stat).round(2)
64
+ puts [stat[:id],
65
+ metric.call(stat),
66
+ "count=#{stat[:count]}",
67
+ "apdex=#{apdex}",
68
+ (apdex >= options[:apdex_goal] ? "OK" : "SLOW")
69
+ ].join(' ')
70
+ end
71
+ end
72
+
73
+ def self.print_heading(heading)
74
+ puts "\n-----------------------------------------------------------"
75
+ puts heading
76
+ puts "-----------------------------------------------------------\n\n"
77
+ end
78
+
79
+
80
+ def self.print_kpi(kpi)
81
+ kpi.each do |key, value|
82
+ puts "#{key}: #{value}"
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,3 @@
1
+ module LogStats
2
+ VERSION = "0.3.0"
3
+ end
data/lib/log_stats.rb ADDED
@@ -0,0 +1,46 @@
1
+ require "json"
2
+ require "log_stats/version"
3
+ require "log_stats/line_parser"
4
+ require "log_stats/logger"
5
+ require "log_stats/requests/stats"
6
+ require "log_stats/requests/kpi"
7
+ require "log_stats/requests/text_output"
8
+
9
+ module LogStats
10
+ def self.run(log_data, config)
11
+ data = get_data(log_data, config)
12
+ if config[:stats_format] == "text" && request_config = config[:events][:requests]
13
+ Requests::TextOutput.print(data[:requests], request_config)
14
+ end
15
+ if config[:stats_format] == "json"
16
+ puts JSON.generate(data)
17
+ end
18
+ data
19
+ end
20
+
21
+ def self.get_data(log_data, config)
22
+ logger = Logger.new(config[:verbose])
23
+ logger.info("\nParsing request lines...")
24
+ data = LineParser.parse(log_data, config)
25
+ result = {}
26
+ if requests = data[:requests]
27
+ requests_count = requests.size
28
+ requests_config = config[:events][:requests]
29
+ logger.info("\nNumber of request lines: #{requests_count}")
30
+ logger.info("Start time: #{requests[0][:time]}")
31
+ logger.info("End time: #{requests[-1][:time]}")
32
+
33
+ logger.info("\nCalculating request stats...")
34
+ stats = Requests::Stats.stats(requests, requests_config)
35
+ result[:requests] = {
36
+ requests_count: requests_count,
37
+ requests: requests,
38
+ stats: stats,
39
+ requests_by_status: requests.group_by { |request| request[:status] },
40
+ requests_by_code: requests.group_by { |request| request[:code] },
41
+ kpi: Requests::KPI.calculate(requests, stats)
42
+ }
43
+ end
44
+ result
45
+ end
46
+ end
data/log_stats.gemspec ADDED
@@ -0,0 +1,36 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'log_stats/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "log_stats"
8
+ spec.version = LogStats::VERSION
9
+ spec.authors = ["Peter Marklund"]
10
+ spec.email = ["peter@marklunds.com"]
11
+
12
+ spec.summary = %q{Extract request statistics from Heroku logs}
13
+ spec.description = %q{}
14
+ spec.homepage = "https://github.com/peter/log_stats"
15
+ spec.license = "MIT"
16
+
17
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
18
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
19
+ if spec.respond_to?(:metadata)
20
+ spec.metadata['allowed_push_host'] = "https://rubygems.org"
21
+ else
22
+ raise "RubyGems 2.0 or newer is required to protect against " \
23
+ "public gem pushes."
24
+ end
25
+
26
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
27
+ f.match(%r{^(test|spec|features)/})
28
+ end
29
+ spec.bindir = "bin"
30
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
31
+ spec.require_paths = ["lib"]
32
+
33
+ spec.add_development_dependency "bundler", "~> 1.13"
34
+ spec.add_development_dependency "rake", "~> 10.0"
35
+ spec.add_development_dependency "minitest", "~> 5.0"
36
+ end
metadata ADDED
@@ -0,0 +1,105 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: log_stats
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Peter Marklund
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-02-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.13'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.13'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '5.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '5.0'
55
+ description: ''
56
+ email:
57
+ - peter@marklunds.com
58
+ executables:
59
+ - log_stats
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - ".gitignore"
64
+ - ".travis.yml"
65
+ - Gemfile
66
+ - LICENSE.txt
67
+ - README.md
68
+ - Rakefile
69
+ - bin/log_stats
70
+ - lib/log_stats.rb
71
+ - lib/log_stats/config.rb
72
+ - lib/log_stats/line_parser.rb
73
+ - lib/log_stats/logger.rb
74
+ - lib/log_stats/requests/kpi.rb
75
+ - lib/log_stats/requests/stats.rb
76
+ - lib/log_stats/requests/text_output.rb
77
+ - lib/log_stats/version.rb
78
+ - log_stats.gemspec
79
+ homepage: https://github.com/peter/log_stats
80
+ licenses:
81
+ - MIT
82
+ metadata:
83
+ allowed_push_host: https://rubygems.org
84
+ post_install_message:
85
+ rdoc_options: []
86
+ require_paths:
87
+ - lib
88
+ required_ruby_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubyforge_project:
100
+ rubygems_version: 2.5.1
101
+ signing_key:
102
+ specification_version: 4
103
+ summary: Extract request statistics from Heroku logs
104
+ test_files: []
105
+ has_rdoc: