request-log-analyzer 1.3.4 → 1.3.5
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/lib/request_log_analyzer/aggregator/database_inserter.rb +8 -5
- data/lib/request_log_analyzer/database/base.rb +3 -2
- data/lib/request_log_analyzer/file_format/amazon_s3.rb +6 -5
- data/lib/request_log_analyzer/file_format/apache.rb +18 -10
- data/lib/request_log_analyzer/file_format/rack.rb +11 -0
- data/lib/request_log_analyzer/output/fixed_width.rb +8 -7
- data/lib/request_log_analyzer/request.rb +18 -6
- data/lib/request_log_analyzer/tracker/duration.rb +3 -3
- data/lib/request_log_analyzer/tracker/traffic.rb +186 -0
- data/lib/request_log_analyzer.rb +3 -3
- data/request-log-analyzer.gemspec +4 -4
- data/spec/lib/mocks.rb +5 -2
- data/spec/unit/file_format/apache_format_spec.rb +38 -1
- data/spec/unit/tracker/traffic_tracker_spec.rb +105 -0
- metadata +8 -4
@@ -57,11 +57,14 @@ module RequestLogAnalyzer::Aggregator
|
|
57
57
|
|
58
58
|
# Records source changes in the sources table
|
59
59
|
def source_change(change, filename)
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
60
|
+
if File.exist?(filename)
|
61
|
+
case change
|
62
|
+
when :started
|
63
|
+
p database.source_class
|
64
|
+
@sources[filename] = database.source_class.create!(:filename => filename)
|
65
|
+
when :finished
|
66
|
+
@sources[filename].update_attributes!(:filesize => File.size(filename), :mtime => File.mtime(filename))
|
67
|
+
end
|
65
68
|
end
|
66
69
|
end
|
67
70
|
|
@@ -13,8 +13,9 @@ class RequestLogAnalyzer::Database::Base < ActiveRecord::Base
|
|
13
13
|
def line_type
|
14
14
|
self.class.name.underscore.gsub(/_line$/, '').to_sym
|
15
15
|
end
|
16
|
-
|
17
|
-
|
16
|
+
|
17
|
+
class_inheritable_accessor :line_definition
|
18
|
+
cattr_accessor :database
|
18
19
|
|
19
20
|
def self.subclass_from_line_definition(definition)
|
20
21
|
klass = Class.new(RequestLogAnalyzer::Database::Base)
|
@@ -9,7 +9,7 @@ module RequestLogAnalyzer::FileFormat
|
|
9
9
|
line_definition :access do |line|
|
10
10
|
line.header = true
|
11
11
|
line.footer = true
|
12
|
-
line.regexp = /^([^\ ]+) ([^\ ]+) \[([
|
12
|
+
line.regexp = /^([^\ ]+) ([^\ ]+) \[(\d{2}\/[A-Za-z]{3}\/\d{4}.\d{2}:\d{2}:\d{2})(?: .\d{4})?\] (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}) ([^\ ]+) ([^\ ]+) (\w+(?:\.\w+)*) ([^\ ]+) "([^"]+)" (\d+) ([^\ ]+) (\d+) (\d+) (\d+) (\d+) "([^"]+)" "([^"]+)"/
|
13
13
|
line.captures << { :name => :bucket_owner, :type => :string } <<
|
14
14
|
{ :name => :bucket, :type => :string } <<
|
15
15
|
{ :name => :timestamp, :type => :timestamp } <<
|
@@ -21,8 +21,8 @@ module RequestLogAnalyzer::FileFormat
|
|
21
21
|
{ :name => :request_uri, :type => :string } <<
|
22
22
|
{ :name => :http_status, :type => :integer } <<
|
23
23
|
{ :name => :error_code, :type => :nillable_string } <<
|
24
|
-
{ :name => :bytes_sent, :type => :
|
25
|
-
{ :name => :object_size, :type => :
|
24
|
+
{ :name => :bytes_sent, :type => :traffic, :unit => :byte } <<
|
25
|
+
{ :name => :object_size, :type => :traffic, :unit => :byte } <<
|
26
26
|
{ :name => :total_time, :type => :duration, :unit => :msec } <<
|
27
27
|
{ :name => :turnaround_time, :type => :duration, :unit => :msec } <<
|
28
28
|
{ :name => :referer, :type => :referer } <<
|
@@ -34,7 +34,8 @@ module RequestLogAnalyzer::FileFormat
|
|
34
34
|
analyze.hourly_spread
|
35
35
|
|
36
36
|
analyze.frequency :category => lambda { |r| "#{r[:bucket]}/#{r[:key]}"}, :amount => 20, :title => "Most popular items"
|
37
|
-
analyze.duration :duration => :total_time, :category => lambda { |r| "#{r[:bucket]}/#{r[:key]}"}, :amount => 20, :title => "
|
37
|
+
analyze.duration :duration => :total_time, :category => lambda { |r| "#{r[:bucket]}/#{r[:key]}"}, :amount => 20, :title => "Request duration"
|
38
|
+
analyze.traffic :traffic => :bytes_sent, :category => lambda { |r| "#{r[:bucket]}/#{r[:key]}"}, :amount => 20, :title => "Traffic"
|
38
39
|
analyze.frequency :category => :http_status, :title => 'HTTP status codes'
|
39
40
|
analyze.frequency :category => :error_code, :title => 'Error codes'
|
40
41
|
end
|
@@ -47,7 +48,7 @@ module RequestLogAnalyzer::FileFormat
|
|
47
48
|
# Do not use DateTime.parse, but parse the timestamp ourselves to return a integer
|
48
49
|
# to speed up parsing.
|
49
50
|
def convert_timestamp(value, definition)
|
50
|
-
d = /^(\d{2})\/(
|
51
|
+
d = /^(\d{2})\/([A-Za-z]{3})\/(\d{4}).(\d{2}):(\d{2}):(\d{2})/.match(value).captures
|
51
52
|
"#{d[2]}#{MONTHS[d[1]]}#{d[0]}#{d[3]}#{d[4]}#{d[5]}".to_i
|
52
53
|
end
|
53
54
|
|
@@ -17,26 +17,30 @@ module RequestLogAnalyzer::FileFormat
|
|
17
17
|
# A hash of predefined Apache log formats
|
18
18
|
LOG_FORMAT_DEFAULTS = {
|
19
19
|
:common => '%h %l %u %t "%r" %>s %b',
|
20
|
-
:combined => '%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-agent}i"'
|
20
|
+
:combined => '%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-agent}i"',
|
21
|
+
:rack => '%h %l %u %t "%r" %>s %b %T',
|
22
|
+
:referer => '%{Referer}i -> %U',
|
23
|
+
:agent => '%{User-agent}i'
|
21
24
|
}
|
22
|
-
|
25
|
+
|
23
26
|
# A hash that defines how the log format directives should be parsed.
|
24
27
|
LOG_DIRECTIVES = {
|
25
28
|
'%' => { :regexp => '%', :captures => [] },
|
26
29
|
'h' => { :regexp => '([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+)', :captures => [{:name => :remote_host, :type => :string}] },
|
27
30
|
'a' => { :regexp => '(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', :captures => [{:name => :remote_ip, :type => :string}] },
|
28
|
-
'b' => { :regexp => '(\d+|-)', :captures => [{:name => :bytes_sent, :type => :
|
31
|
+
'b' => { :regexp => '(\d+|-)', :captures => [{:name => :bytes_sent, :type => :traffic}] },
|
29
32
|
'c' => { :regexp => '(\+|\-|\X)', :captures => [{:name => :connection_status, :type => :integer}] },
|
30
|
-
'D' => { :regexp => '(\d+|-)', :captures => [{:name => :duration, :type => :duration, :unit => :
|
33
|
+
'D' => { :regexp => '(\d+|-)', :captures => [{:name => :duration, :type => :duration, :unit => :musec}] },
|
31
34
|
'l' => { :regexp => '([\w-]+)', :captures => [{:name => :remote_logname, :type => :nillable_string}] },
|
32
|
-
'T' => { :regexp => '(
|
33
|
-
't' => { :regexp => '\[([
|
35
|
+
'T' => { :regexp => '((?:\d+(?:\.\d+))|-)', :captures => [{:name => :duration, :type => :duration, :unit => :sec}] },
|
36
|
+
't' => { :regexp => '\[(\d{2}\/[A-Za-z]{3}\/\d{4}.\d{2}:\d{2}:\d{2})(?: .\d{4})?\]', :captures => [{:name => :timestamp, :type => :timestamp}] },
|
34
37
|
's' => { :regexp => '(\d{3})', :captures => [{:name => :http_status, :type => :integer}] },
|
35
38
|
'u' => { :regexp => '(\w+|-)', :captures => [{:name => :user, :type => :nillable_string}] },
|
36
|
-
'
|
39
|
+
'U' => { :regexp => '(\/\S*)', :captures => [{:name => :path, :type => :string}] },
|
40
|
+
'r' => { :regexp => '([A-Z]+) (\S+) HTTP\/(\d+(?:\.\d+)*)', :captures => [{:name => :http_method, :type => :string},
|
37
41
|
{:name => :path, :type => :path}, {:name => :http_version, :type => :string}]},
|
38
|
-
'i' => { 'Referer' => { :regexp => '(
|
39
|
-
'User-agent' => { :regexp => '(.*)',
|
42
|
+
'i' => { 'Referer' => { :regexp => '(\S+)', :captures => [{:name => :referer, :type => :nillable_string}] },
|
43
|
+
'User-agent' => { :regexp => '(.*)', :captures => [{:name => :user_agent, :type => :user_agent}] }
|
40
44
|
}
|
41
45
|
}
|
42
46
|
|
@@ -97,6 +101,10 @@ module RequestLogAnalyzer::FileFormat
|
|
97
101
|
analyze.duration :duration => :duration, :category => :path , :title => 'Request duration'
|
98
102
|
end
|
99
103
|
|
104
|
+
if line_definition.captures?(:path) && line_definition.captures?(:bytes_sent)
|
105
|
+
analyze.traffic :traffic => :bytes_sent, :category => :path , :title => 'Traffic'
|
106
|
+
end
|
107
|
+
|
100
108
|
return analyze.trackers
|
101
109
|
end
|
102
110
|
|
@@ -109,7 +117,7 @@ module RequestLogAnalyzer::FileFormat
|
|
109
117
|
# Do not use DateTime.parse, but parse the timestamp ourselves to return a integer
|
110
118
|
# to speed up parsing.
|
111
119
|
def convert_timestamp(value, definition)
|
112
|
-
d = /^(\d{2})\/(
|
120
|
+
d = /^(\d{2})\/([A-Za-z]{3})\/(\d{4}).(\d{2}):(\d{2}):(\d{2})/.match(value).captures
|
113
121
|
"#{d[2]}#{MONTHS[d[1]]}#{d[0]}#{d[3]}#{d[4]}#{d[5]}".to_i
|
114
122
|
end
|
115
123
|
|
@@ -104,7 +104,7 @@ module RequestLogAnalyzer::Output
|
|
104
104
|
end
|
105
105
|
|
106
106
|
# Write a link
|
107
|
-
# <tt>text</tt> The text in the link
|
107
|
+
# <tt>text</tt> The text in the link, or the URL itself if no text is given
|
108
108
|
# <tt>url</tt> The url to link to.
|
109
109
|
def link(text, url = nil)
|
110
110
|
if url.nil?
|
@@ -114,13 +114,13 @@ module RequestLogAnalyzer::Output
|
|
114
114
|
end
|
115
115
|
end
|
116
116
|
|
117
|
-
# Generate a header for a report
|
117
|
+
# Generate a header for a report
|
118
118
|
def header
|
119
119
|
if io.kind_of?(File)
|
120
|
-
puts "Request-log-analyzer summary report"
|
121
|
-
line
|
120
|
+
puts colorize("Request-log-analyzer summary report", :white, :bold)
|
121
|
+
line(:green)
|
122
122
|
puts "Version #{RequestLogAnalyzer::VERSION} - written by Willem van Bergen and Bart ten Brinke"
|
123
|
-
puts "
|
123
|
+
puts "Website: #{link('http://github.com/wvanbergen/request-log-analyzer')}"
|
124
124
|
end
|
125
125
|
end
|
126
126
|
|
@@ -128,8 +128,9 @@ module RequestLogAnalyzer::Output
|
|
128
128
|
def footer
|
129
129
|
puts
|
130
130
|
puts "Need an expert to analyze your application?"
|
131
|
-
puts "Mail to contact@railsdoctors.com or visit us at http://railsdoctors.com"
|
132
|
-
|
131
|
+
puts "Mail to #{link('contact@railsdoctors.com')} or visit us at #{link('http://railsdoctors.com')}."
|
132
|
+
line(:green)
|
133
|
+
puts "Thanks for using #{colorize('request-log-analyzer', :white, :bold)}!"
|
133
134
|
end
|
134
135
|
|
135
136
|
# Generate a report table and push it into the output object.
|
@@ -41,13 +41,25 @@ module RequestLogAnalyzer
|
|
41
41
|
DateTime.parse(value).strftime('%Y%m%d%H%M%S').to_i unless value.nil?
|
42
42
|
end
|
43
43
|
|
44
|
+
def convert_traffic(value, capture_definition)
|
45
|
+
return nil if value.nil?
|
46
|
+
case capture_definition[:unit]
|
47
|
+
when :GB, :G, :gigabyte then (value.to_f * 1000_000_000).round
|
48
|
+
when :GiB, :gibibyte then (value.to_f * (2 ** 30)).round
|
49
|
+
when :MB, :M, :megabyte then (value.to_f * 1000_000).round
|
50
|
+
when :MiB, :mebibyte then (value.to_f * (2 ** 20)).round
|
51
|
+
when :KB, :K, :kilobyte, :kB then (value.to_f * 1000).round
|
52
|
+
when :KiB, :kibibyte then (value.to_f * (2 ** 10)).round
|
53
|
+
else value.to_i
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
44
57
|
def convert_duration(value, capture_definition)
|
45
|
-
if value.nil?
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
else
|
50
|
-
value.to_f
|
58
|
+
return nil if value.nil?
|
59
|
+
case capture_definition[:unit]
|
60
|
+
when :microsec, :musec then value.to_f / 1000000.0
|
61
|
+
when :msec, :millisec then value.to_f / 1000.0
|
62
|
+
else value.to_f
|
51
63
|
end
|
52
64
|
end
|
53
65
|
end
|
@@ -51,10 +51,10 @@ module RequestLogAnalyzer::Tracker
|
|
51
51
|
raise "Capture mismatch for multiple values in a request"
|
52
52
|
end
|
53
53
|
else
|
54
|
-
category = @categorizer.call(request)
|
55
|
-
duration = @durationizer.call(request)
|
54
|
+
category = @categorizer.call(request)
|
55
|
+
duration = @durationizer.call(request)
|
56
56
|
|
57
|
-
if duration.kind_of?(
|
57
|
+
if duration.kind_of?(Numeric) && !category.nil?
|
58
58
|
@categories[category] ||= {:hits => 0, :cumulative => 0.0, :min => duration, :max => duration }
|
59
59
|
@categories[category][:hits] += 1
|
60
60
|
@categories[category][:cumulative] += duration
|
@@ -0,0 +1,186 @@
|
|
1
|
+
module RequestLogAnalyzer::Tracker
|
2
|
+
|
3
|
+
# Analyze the average and total traffic of requests
|
4
|
+
#
|
5
|
+
# === Options
|
6
|
+
# * <tt>:amount</tt> The amount of lines in the report
|
7
|
+
# * <tt>:category</tt> Proc that handles request categorization for given fileformat (REQUEST_CATEGORIZER)
|
8
|
+
# * <tt>:traffic</tt> The field containing the duration in the request hash.
|
9
|
+
# * <tt>:if</tt> Proc that has to return !nil for a request to be passed to the tracker.
|
10
|
+
# * <tt>:line_type</tt> The line type that contains the duration field (determined by the category proc).
|
11
|
+
# * <tt>:title</tt> Title do be displayed above the report
|
12
|
+
# * <tt>:unless</tt> Handle request if this proc is false for the handled request.
|
13
|
+
class Traffic < Base
|
14
|
+
|
15
|
+
attr_reader :categories
|
16
|
+
|
17
|
+
# Check if duration and catagory option have been received,
|
18
|
+
def prepare
|
19
|
+
raise "No traffic field set up for category tracker #{self.inspect}" unless options[:traffic]
|
20
|
+
raise "No categorizer set up for duration tracker #{self.inspect}" unless options[:category]
|
21
|
+
|
22
|
+
@categorizer = options[:category].respond_to?(:call) ? options[:category] : lambda { |request| request[options[:category]] }
|
23
|
+
@trafficizer = options[:traffic].respond_to?(:call) ? options[:traffic] : lambda { |request| request[options[:traffic]] }
|
24
|
+
@categories = {}
|
25
|
+
end
|
26
|
+
|
27
|
+
# Get the duration information fron the request and store it in the different categories.
|
28
|
+
# <tt>request</tt> The request.
|
29
|
+
def update(request)
|
30
|
+
category = @categorizer.call(request)
|
31
|
+
traffic = @trafficizer.call(request)
|
32
|
+
|
33
|
+
if traffic.kind_of?(Numeric) && !category.nil?
|
34
|
+
@categories[category] ||= {:hits => 0, :cumulative => 0, :min => traffic, :max => traffic }
|
35
|
+
@categories[category][:hits] += 1
|
36
|
+
@categories[category][:cumulative] += traffic
|
37
|
+
@categories[category][:min] = traffic if traffic < @categories[category][:min]
|
38
|
+
@categories[category][:max] = traffic if traffic > @categories[category][:max]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Get the number of hits of a specific category.
|
43
|
+
# <tt>cat</tt> The category
|
44
|
+
def hits(cat)
|
45
|
+
categories[cat][:hits]
|
46
|
+
end
|
47
|
+
|
48
|
+
# Get the total duration of a specific category.
|
49
|
+
# <tt>cat</tt> The category
|
50
|
+
def cumulative_traffic(cat)
|
51
|
+
categories[cat][:cumulative]
|
52
|
+
end
|
53
|
+
|
54
|
+
# Get the minimal duration of a specific category.
|
55
|
+
# <tt>cat</tt> The category
|
56
|
+
def min_traffic(cat)
|
57
|
+
categories[cat][:min]
|
58
|
+
end
|
59
|
+
|
60
|
+
# Get the maximum duration of a specific category.
|
61
|
+
# <tt>cat</tt> The category
|
62
|
+
def max_traffic(cat)
|
63
|
+
categories[cat][:max]
|
64
|
+
end
|
65
|
+
|
66
|
+
# Get the average duration of a specific category.
|
67
|
+
# <tt>cat</tt> The category
|
68
|
+
def average_traffic(cat)
|
69
|
+
categories[cat][:cumulative].to_f / categories[cat][:hits]
|
70
|
+
end
|
71
|
+
|
72
|
+
# Get the average duration of a all categories.
|
73
|
+
def overall_average_traffic
|
74
|
+
overall_cumulative_duration.to_f / overall_hits
|
75
|
+
end
|
76
|
+
|
77
|
+
# Get the cumlative duration of a all categories.
|
78
|
+
def overall_cumulative_traffic
|
79
|
+
categories.inject(0) { |sum, (name, cat)| sum + cat[:cumulative] }
|
80
|
+
end
|
81
|
+
|
82
|
+
# Get the total hits of a all categories.
|
83
|
+
def overall_hits
|
84
|
+
categories.inject(0) { |sum, (name, cat)| sum + cat[:hits] }
|
85
|
+
end
|
86
|
+
|
87
|
+
# Return categories sorted by hits.
|
88
|
+
def sorted_by_hits
|
89
|
+
sorted_by(:hits)
|
90
|
+
end
|
91
|
+
|
92
|
+
# Return categories sorted by cumulative duration.
|
93
|
+
def sorted_by_cumulative
|
94
|
+
sorted_by(:cumulative)
|
95
|
+
end
|
96
|
+
|
97
|
+
# Return categories sorted by cumulative duration.
|
98
|
+
def sorted_by_average
|
99
|
+
sorted_by { |cat| cat[:cumulative].to_f / cat[:hits] }
|
100
|
+
end
|
101
|
+
|
102
|
+
# Return categories sorted by a given key.
|
103
|
+
# <tt>by</tt> The key.
|
104
|
+
def sorted_by(by = nil)
|
105
|
+
if block_given?
|
106
|
+
categories.sort { |a, b| yield(b[1]) <=> yield(a[1]) }
|
107
|
+
else
|
108
|
+
categories.sort { |a, b| b[1][by] <=> a[1][by] }
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
# Block function to build a result table using a provided sorting function.
|
113
|
+
# <tt>output</tt> The output object.
|
114
|
+
# <tt>amount</tt> The number of rows in the report table (default 10).
|
115
|
+
# === Options
|
116
|
+
# * </tt>:title</tt> The title of the table
|
117
|
+
# * </tt>:sort</tt> The key to sort on (:hits, :cumulative, :average, :min or :max)
|
118
|
+
def report_table(output, amount = 10, options = {}, &block)
|
119
|
+
|
120
|
+
output.title(options[:title])
|
121
|
+
|
122
|
+
top_categories = @categories.sort { |a, b| yield(b[1]) <=> yield(a[1]) }.slice(0...amount)
|
123
|
+
output.table({:title => 'Category', :width => :rest},
|
124
|
+
{:title => 'Hits', :align => :right, :highlight => (options[:sort] == :hits), :min_width => 4},
|
125
|
+
{:title => 'Cumulative', :align => :right, :highlight => (options[:sort] == :cumulative), :min_width => 10},
|
126
|
+
{:title => 'Average', :align => :right, :highlight => (options[:sort] == :average), :min_width => 8},
|
127
|
+
{:title => 'Min', :align => :right, :highlight => (options[:sort] == :min)},
|
128
|
+
{:title => 'Max', :align => :right, :highlight => (options[:sort] == :max)}) do |rows|
|
129
|
+
|
130
|
+
top_categories.each do |(cat, info)|
|
131
|
+
rows << [cat, info[:hits], format_traffic(info[:cumulative]), format_traffic((info[:cumulative] / info[:hits]).round),
|
132
|
+
format_traffic(info[:min]), format_traffic(info[:max])]
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
# Formats the traffic number using x B/kB/MB/GB etc notation
|
138
|
+
def format_traffic(bytes)
|
139
|
+
return "0 B" if bytes.zero?
|
140
|
+
case Math.log10(bytes).floor
|
141
|
+
when 1...4 then '%d B' % bytes
|
142
|
+
when 4...7 then '%d kB' % (bytes / 1000)
|
143
|
+
when 7...10 then '%d MB' % (bytes / 1000_000)
|
144
|
+
when 10...13 then '%d GB' % (bytes / 1000_000_000)
|
145
|
+
else '%d TB' % (bytes / 1000_000_000_000)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
# Generate a request duration report to the given output object
|
150
|
+
# By default colulative and average duration are generated.
|
151
|
+
# Any options for the report should have been set during initialize.
|
152
|
+
# <tt>output</tt> The output object
|
153
|
+
def report(output)
|
154
|
+
|
155
|
+
options[:report] ||= [:cumulative, :average]
|
156
|
+
options[:top] ||= 20
|
157
|
+
|
158
|
+
options[:report].each do |report|
|
159
|
+
case report
|
160
|
+
when :average
|
161
|
+
report_table(output, options[:top], :title => "#{title} - top #{options[:top]} by average", :sort => :average) { |cat| cat[:cumulative] / cat[:hits] }
|
162
|
+
when :cumulative
|
163
|
+
report_table(output, options[:top], :title => "#{title} - top #{options[:top]} by sum", :sort => :cumulative) { |cat| cat[:cumulative] }
|
164
|
+
when :hits
|
165
|
+
report_table(output, options[:top], :title => "#{title} - top #{options[:top]} by hits", :sort => :hits) { |cat| cat[:hits] }
|
166
|
+
else
|
167
|
+
raise "Unknown duration report specified: #{report}!"
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
output.puts
|
172
|
+
output.puts "#{output.colorize(title, :white, :bold)} - observed total: " + output.colorize(format_traffic(overall_cumulative_traffic), :brown, :bold)
|
173
|
+
end
|
174
|
+
|
175
|
+
# Returns the title of this tracker for reports
|
176
|
+
def title
|
177
|
+
options[:title] || 'Request traffic'
|
178
|
+
end
|
179
|
+
|
180
|
+
# Returns all the categories and the tracked duration as a hash than can be exported to YAML
|
181
|
+
def to_yaml_object
|
182
|
+
return nil if @categories.empty?
|
183
|
+
@categories
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
data/lib/request_log_analyzer.rb
CHANGED
@@ -10,8 +10,8 @@ Encoding.default_external = 'binary' if defined? Encoding and Encoding.respond_t
|
|
10
10
|
module RequestLogAnalyzer
|
11
11
|
|
12
12
|
# The current version of request-log-analyzer.
|
13
|
-
# This will be diplayed in output reports etc.
|
14
|
-
VERSION = "1.3.
|
13
|
+
# This will be diplayed in output reports etc.
|
14
|
+
VERSION = "1.3.5"
|
15
15
|
|
16
16
|
# Loads constants in the RequestLogAnalyzer namespace using self.load_default_class_file(base, const)
|
17
17
|
# <tt>const</tt>:: The constant that is not yet loaded in the RequestLogAnalyzer namespace. This should be passed as a string or symbol.
|
@@ -25,7 +25,7 @@ module RequestLogAnalyzer
|
|
25
25
|
# <tt>const</tt>:: The constant to load from the base constant as a string or symbol. This should be 'Bar' or :Bar when the constant Foo::Bar is being loaded.
|
26
26
|
def self.load_default_class_file(base, const)
|
27
27
|
require "#{to_underscore("#{base.name}::#{const}")}"
|
28
|
-
base.const_get(const)
|
28
|
+
base.const_get(const) if base.const_defined?(const)
|
29
29
|
end
|
30
30
|
|
31
31
|
# Convert a string/symbol in camelcase (RequestLogAnalyzer::Controller) to underscores (request_log_analyzer/controller)
|
@@ -1,7 +1,7 @@
|
|
1
1
|
Gem::Specification.new do |s|
|
2
2
|
s.name = "request-log-analyzer"
|
3
|
-
s.version = "1.3.
|
4
|
-
s.date = "2009-09-
|
3
|
+
s.version = "1.3.5"
|
4
|
+
s.date = "2009-09-16"
|
5
5
|
|
6
6
|
s.rubyforge_project = 'r-l-a'
|
7
7
|
|
@@ -31,6 +31,6 @@ Gem::Specification.new do |s|
|
|
31
31
|
s.email = ['willem@railsdoctors.com', 'bart@railsdoctors.com']
|
32
32
|
s.homepage = 'http://railsdoctors.com'
|
33
33
|
|
34
|
-
s.files = %w(spec/unit/filter/anonymize_filter_spec.rb lib/request_log_analyzer/line_definition.rb lib/request_log_analyzer/output/html.rb lib/request_log_analyzer/controller.rb spec/fixtures/rails_22_cached.log spec/lib/macros.rb lib/request_log_analyzer/file_format/rails_development.rb spec/fixtures/apache_combined.log spec/fixtures/apache_common.log spec/fixtures/merb_prefixed.log lib/request_log_analyzer/file_format/amazon_s3.rb tasks/request_log_analyzer.rake spec/unit/file_format/file_format_api_spec.rb spec/unit/file_format/apache_format_spec.rb spec/integration/command_line_usage_spec.rb lib/request_log_analyzer/database.rb spec/fixtures/decompression.log.bz2 lib/request_log_analyzer/log_processor.rb lib/request_log_analyzer/tracker.rb lib/request_log_analyzer/filter.rb
|
35
|
-
s.test_files = %w(spec/unit/filter/anonymize_filter_spec.rb spec/unit/file_format/file_format_api_spec.rb spec/unit/file_format/apache_format_spec.rb spec/integration/command_line_usage_spec.rb spec/unit/filter/timespan_filter_spec.rb spec/unit/aggregator/database_inserter_spec.rb spec/unit/tracker/tracker_api_spec.rb spec/unit/tracker/duration_tracker_spec.rb spec/unit/file_format/amazon_s3_format_spec.rb spec/unit/controller/log_processor_spec.rb spec/unit/filter/filter_spec.rb spec/unit/filter/field_filter_spec.rb spec/unit/database/base_class_spec.rb spec/unit/tracker/timespan_tracker_spec.rb spec/unit/tracker/hourly_spread_spec.rb spec/unit/file_format/merb_format_spec.rb spec/unit/file_format/line_definition_spec.rb spec/unit/database/connection_spec.rb spec/unit/controller/controller_spec.rb spec/unit/source/request_spec.rb spec/unit/source/log_parser_spec.rb spec/unit/database/database_spec.rb spec/unit/aggregator/summarizer_spec.rb spec/unit/tracker/frequency_tracker_spec.rb spec/unit/file_format/rails_format_spec.rb)
|
34
|
+
s.files = %w(spec/unit/filter/anonymize_filter_spec.rb lib/request_log_analyzer/line_definition.rb lib/request_log_analyzer/output/html.rb lib/request_log_analyzer/controller.rb spec/fixtures/rails_22_cached.log spec/lib/macros.rb lib/request_log_analyzer/file_format/rails_development.rb spec/fixtures/apache_combined.log spec/fixtures/apache_common.log spec/fixtures/merb_prefixed.log lib/request_log_analyzer/file_format/amazon_s3.rb tasks/request_log_analyzer.rake spec/unit/file_format/file_format_api_spec.rb spec/unit/file_format/apache_format_spec.rb spec/integration/command_line_usage_spec.rb lib/request_log_analyzer/database.rb spec/fixtures/decompression.log.bz2 spec/fixtures/rails_unordered.log lib/request_log_analyzer/log_processor.rb lib/request_log_analyzer/tracker.rb lib/request_log_analyzer/filter.rb bin/request-log-analyzer request-log-analyzer.gemspec DESIGN.rdoc spec/unit/filter/timespan_filter_spec.rb spec/unit/aggregator/database_inserter_spec.rb spec/lib/matchers.rb lib/request_log_analyzer/filter/field.rb lib/request_log_analyzer/tracker/frequency.rb spec/fixtures/decompression.log.gz spec/fixtures/decompression.log spec/lib/testing_format.rb spec/fixtures/test_order.log spec/fixtures/rails.db lib/request_log_analyzer/output/fixed_width.rb lib/request_log_analyzer/filter/anonymize.rb lib/request_log_analyzer/tracker/timespan.rb lib/request_log_analyzer/database/base.rb lib/request_log_analyzer/aggregator.rb lib/cli/progressbar.rb lib/request_log_analyzer/mailer.rb README.rdoc spec/fixtures/merb.log lib/request_log_analyzer/tracker/hourly_spread.rb .gitignore spec/unit/tracker/tracker_api_spec.rb spec/unit/tracker/duration_tracker_spec.rb spec/unit/file_format/amazon_s3_format_spec.rb lib/request_log_analyzer/aggregator/echo.rb spec/unit/controller/log_processor_spec.rb spec/spec_helper.rb lib/request_log_analyzer.rb spec/database.yml Rakefile lib/request_log_analyzer/database/connection.rb spec/unit/filter/filter_spec.rb spec/fixtures/test_language_combined.log lib/request_log_analyzer/aggregator/database_inserter.rb lib/request_log_analyzer/aggregator/summarizer.rb lib/request_log_analyzer/file_format/rack.rb lib/request_log_analyzer/file_format/rails.rb spec/fixtures/decompression.tar.gz spec/unit/tracker/traffic_tracker_spec.rb spec/unit/filter/field_filter_spec.rb spec/unit/database/base_class_spec.rb lib/request_log_analyzer/filter/timespan.rb lib/request_log_analyzer/source/log_parser.rb spec/fixtures/decompression.tgz spec/unit/tracker/timespan_tracker_spec.rb spec/unit/tracker/hourly_spread_spec.rb spec/fixtures/header_and_footer.log lib/cli/tools.rb lib/request_log_analyzer/file_format/merb.rb spec/fixtures/multiple_files_1.log spec/unit/file_format/merb_format_spec.rb spec/unit/file_format/line_definition_spec.rb lib/request_log_analyzer/source.rb lib/request_log_analyzer/request.rb lib/cli/database_console.rb spec/unit/database/connection_spec.rb spec/unit/controller/controller_spec.rb spec/lib/mocks.rb spec/lib/helpers.rb lib/cli/database_console_init.rb lib/request_log_analyzer/output.rb lib/request_log_analyzer/file_format/apache.rb spec/fixtures/rails_1x.log spec/fixtures/decompression.log.zip spec/unit/source/request_spec.rb spec/unit/source/log_parser_spec.rb spec/fixtures/test_file_format.log tasks/github-gem.rake spec/unit/database/database_spec.rb lib/request_log_analyzer/tracker/duration.rb lib/request_log_analyzer/tracker/traffic.rb lib/request_log_analyzer/file_format.rb spec/unit/aggregator/summarizer_spec.rb spec/fixtures/syslog_1x.log spec/fixtures/rails_22.log spec/fixtures/multiple_files_2.log LICENSE lib/request_log_analyzer/source/database_loader.rb spec/unit/tracker/frequency_tracker_spec.rb spec/unit/file_format/rails_format_spec.rb lib/cli/command_line_arguments.rb)
|
35
|
+
s.test_files = %w(spec/unit/filter/anonymize_filter_spec.rb spec/unit/file_format/file_format_api_spec.rb spec/unit/file_format/apache_format_spec.rb spec/integration/command_line_usage_spec.rb spec/unit/filter/timespan_filter_spec.rb spec/unit/aggregator/database_inserter_spec.rb spec/unit/tracker/tracker_api_spec.rb spec/unit/tracker/duration_tracker_spec.rb spec/unit/file_format/amazon_s3_format_spec.rb spec/unit/controller/log_processor_spec.rb spec/unit/filter/filter_spec.rb spec/unit/tracker/traffic_tracker_spec.rb spec/unit/filter/field_filter_spec.rb spec/unit/database/base_class_spec.rb spec/unit/tracker/timespan_tracker_spec.rb spec/unit/tracker/hourly_spread_spec.rb spec/unit/file_format/merb_format_spec.rb spec/unit/file_format/line_definition_spec.rb spec/unit/database/connection_spec.rb spec/unit/controller/controller_spec.rb spec/unit/source/request_spec.rb spec/unit/source/log_parser_spec.rb spec/unit/database/database_spec.rb spec/unit/aggregator/summarizer_spec.rb spec/unit/tracker/frequency_tracker_spec.rb spec/unit/file_format/rails_format_spec.rb)
|
36
36
|
end
|
data/spec/lib/mocks.rb
CHANGED
@@ -25,7 +25,7 @@ module RequestLogAnalyzer::Spec::Mocks
|
|
25
25
|
def mock_io
|
26
26
|
mio = mock('IO')
|
27
27
|
mio.stub!(:print)
|
28
|
-
mio.stub!(:puts)
|
28
|
+
mio.stub!(:puts)
|
29
29
|
mio.stub!(:write)
|
30
30
|
return mio
|
31
31
|
end
|
@@ -35,12 +35,15 @@ module RequestLogAnalyzer::Spec::Mocks
|
|
35
35
|
output.stub!(:header)
|
36
36
|
output.stub!(:footer)
|
37
37
|
output.stub!(:puts)
|
38
|
-
output.stub!(:<<)
|
38
|
+
output.stub!(:<<)
|
39
|
+
output.stub!(:colorize).and_return("Fancy text")
|
40
|
+
output.stub!(:link)
|
39
41
|
output.stub!(:title)
|
40
42
|
output.stub!(:line)
|
41
43
|
output.stub!(:with_style)
|
42
44
|
output.stub!(:table).and_yield([])
|
43
45
|
output.stub!(:io).and_return(mock_io)
|
46
|
+
|
44
47
|
return output
|
45
48
|
end
|
46
49
|
|
@@ -99,12 +99,49 @@ describe RequestLogAnalyzer::FileFormat::Apache do
|
|
99
99
|
@log_parser.parse_file(log_fixture(:apache_common)) { counter.hit! }
|
100
100
|
end
|
101
101
|
end
|
102
|
+
|
103
|
+
context '"Rack" access log parser' do
|
104
|
+
before(:each) do
|
105
|
+
@file_format = RequestLogAnalyzer::FileFormat.load(:rack)
|
106
|
+
@log_parser = RequestLogAnalyzer::Source::LogParser.new(@file_format)
|
107
|
+
@sample_1 = '127.0.0.1 - - [16/Sep/2009 06:40:08] "GET /favicon.ico HTTP/1.1" 500 63183 0.0453'
|
108
|
+
end
|
109
|
+
|
110
|
+
it "should create a kind of an Apache file format" do
|
111
|
+
@file_format.should be_kind_of(RequestLogAnalyzer::FileFormat::Apache)
|
112
|
+
end
|
113
|
+
|
114
|
+
it "should have a valid language definitions" do
|
115
|
+
@file_format.should be_valid
|
116
|
+
end
|
117
|
+
|
118
|
+
it "should parse a valid access log line" do
|
119
|
+
@file_format.line_definitions[:access].matches(@sample_1).should be_kind_of(Hash)
|
120
|
+
end
|
121
|
+
|
122
|
+
it "should not parse a valid access log line" do
|
123
|
+
@file_format.line_definitions[:access].matches('addasdsasadadssadasd').should be_false
|
124
|
+
end
|
125
|
+
|
126
|
+
it "should read the correct values from a valid 404 access log line" do
|
127
|
+
@log_parser.parse_io(StringIO.new(@sample_1)) do |request|
|
128
|
+
request[:remote_host].should == '127.0.0.1'
|
129
|
+
request[:timestamp].should == 20090916064008
|
130
|
+
request[:http_status].should == 500
|
131
|
+
request[:http_method].should == 'GET'
|
132
|
+
request[:http_version].should == '1.1'
|
133
|
+
request[:bytes_sent].should == 63183
|
134
|
+
request[:user].should == nil
|
135
|
+
request[:duration].should == 0.0453
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
102
139
|
|
103
140
|
context '"Combined" access log parsing' do
|
104
141
|
|
105
142
|
before(:all) do
|
106
143
|
@file_format = RequestLogAnalyzer::FileFormat.load(:apache, :combined)
|
107
|
-
@log_parser
|
144
|
+
@log_parser = RequestLogAnalyzer::Source::LogParser.new(@file_format)
|
108
145
|
@sample_1 = '69.41.0.45 - - [02/Sep/2009:12:02:40 +0200] "GET //phpMyAdmin/ HTTP/1.1" 404 209 "-" "Mozilla/4.0 (compatible; MSIE 6.0; Windows 98)"'
|
109
146
|
@sample_2 = '10.0.1.1 - - [02/Sep/2009:05:08:33 +0200] "GET / HTTP/1.1" 200 30 "-" "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_8; en-us) AppleWebKit/531.9 (KHTML, like Gecko) Version/4.0.3 Safari/531.9"'
|
110
147
|
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/../../spec_helper'
|
2
|
+
|
3
|
+
describe RequestLogAnalyzer::Tracker::Traffic do
|
4
|
+
|
5
|
+
describe '#update' do
|
6
|
+
|
7
|
+
context 'using a field-based category' do
|
8
|
+
before(:each) do
|
9
|
+
@tracker = RequestLogAnalyzer::Tracker::Traffic.new(:traffic => :traffic, :category => :category)
|
10
|
+
@tracker.prepare
|
11
|
+
end
|
12
|
+
|
13
|
+
it "should register a request in the right category" do
|
14
|
+
@tracker.update(request(:category => 'a', :traffic => 200))
|
15
|
+
@tracker.categories.should include('a')
|
16
|
+
end
|
17
|
+
|
18
|
+
it "should register a hit in the right category" do
|
19
|
+
@tracker.update(request(:category => 'a', :traffic => 1))
|
20
|
+
@tracker.update(request(:category => 'b', :traffic => 2))
|
21
|
+
@tracker.update(request(:category => 'b', :traffic => 3))
|
22
|
+
|
23
|
+
@tracker.hits('a').should == 1
|
24
|
+
@tracker.hits('b').should == 2
|
25
|
+
end
|
26
|
+
|
27
|
+
it "should sum the traffics of the same category as cumulative traffic" do
|
28
|
+
@tracker.update(request(:category => 'a', :traffic => 1))
|
29
|
+
@tracker.update(request(:category => 'b', :traffic => 2))
|
30
|
+
@tracker.update(request(:category => 'b', :traffic => 3))
|
31
|
+
|
32
|
+
@tracker.cumulative_traffic('a').should == 1
|
33
|
+
@tracker.cumulative_traffic('b').should == 5
|
34
|
+
end
|
35
|
+
|
36
|
+
it "should calculate the average traffic correctly" do
|
37
|
+
@tracker.update(request(:category => 'a', :traffic => 1))
|
38
|
+
@tracker.update(request(:category => 'b', :traffic => 2))
|
39
|
+
@tracker.update(request(:category => 'b', :traffic => 3))
|
40
|
+
|
41
|
+
@tracker.average_traffic('a').should == 1.0
|
42
|
+
@tracker.average_traffic('b').should == 2.5
|
43
|
+
end
|
44
|
+
|
45
|
+
it "should set min and max traffic correctly" do
|
46
|
+
@tracker.update(request(:category => 'a', :traffic => 1))
|
47
|
+
@tracker.update(request(:category => 'b', :traffic => 2))
|
48
|
+
@tracker.update(request(:category => 'b', :traffic => 3))
|
49
|
+
|
50
|
+
@tracker.min_traffic('b').should == 2
|
51
|
+
@tracker.max_traffic('b').should == 3
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
55
|
+
|
56
|
+
context 'using a dynamic category' do
|
57
|
+
before(:each) do
|
58
|
+
@categorizer = Proc.new { |request| request[:traffic] < 2 ? 'few' : 'lots' }
|
59
|
+
@tracker = RequestLogAnalyzer::Tracker::Traffic.new(:traffic => :traffic, :category => @categorizer)
|
60
|
+
@tracker.prepare
|
61
|
+
end
|
62
|
+
|
63
|
+
it "should use the categorizer to determine the right category" do
|
64
|
+
@tracker.update(request(:category => 'a', :traffic => 1))
|
65
|
+
@tracker.update(request(:category => 'b', :traffic => 2))
|
66
|
+
@tracker.update(request(:category => 'b', :traffic => 3))
|
67
|
+
|
68
|
+
@tracker.categories.should include('few', 'lots')
|
69
|
+
end
|
70
|
+
|
71
|
+
it "should use the categorizer to aggregate the values correctly" do
|
72
|
+
@tracker.update(request(:category => 'a', :traffic => 1))
|
73
|
+
@tracker.update(request(:category => 'b', :traffic => 2))
|
74
|
+
@tracker.update(request(:category => 'b', :traffic => 3))
|
75
|
+
|
76
|
+
@tracker.max_traffic('few').should == 1
|
77
|
+
@tracker.min_traffic('lots').should == 2
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
describe '#report' do
|
83
|
+
before(:each) do
|
84
|
+
@tracker = RequestLogAnalyzer::Tracker::Traffic.new(:category => :category, :traffic => :traffic)
|
85
|
+
@tracker.prepare
|
86
|
+
end
|
87
|
+
|
88
|
+
it "should generate a report without errors when one category is present" do
|
89
|
+
@tracker.update(request(:category => 'a', :traffic => 2))
|
90
|
+
@tracker.report(mock_output)
|
91
|
+
lambda { @tracker.report(mock_output) }.should_not raise_error
|
92
|
+
end
|
93
|
+
|
94
|
+
it "should generate a report without errors when no category is present" do
|
95
|
+
lambda { @tracker.report(mock_output) }.should_not raise_error
|
96
|
+
end
|
97
|
+
|
98
|
+
it "should generate a report without errors when multiple categories are present" do
|
99
|
+
@tracker.update(request(:category => 'a', :traffic => 2))
|
100
|
+
@tracker.update(request(:category => 'b', :traffic => 2))
|
101
|
+
lambda { @tracker.report(mock_output) }.should_not raise_error
|
102
|
+
end
|
103
|
+
|
104
|
+
end
|
105
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: request-log-analyzer
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.3.
|
4
|
+
version: 1.3.5
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Willem van Bergen
|
@@ -10,7 +10,7 @@ autorequire:
|
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
12
|
|
13
|
-
date: 2009-09-
|
13
|
+
date: 2009-09-16 00:00:00 +02:00
|
14
14
|
default_executable: request-log-analyzer
|
15
15
|
dependencies:
|
16
16
|
- !ruby/object:Gem::Dependency
|
@@ -61,10 +61,10 @@ files:
|
|
61
61
|
- spec/integration/command_line_usage_spec.rb
|
62
62
|
- lib/request_log_analyzer/database.rb
|
63
63
|
- spec/fixtures/decompression.log.bz2
|
64
|
+
- spec/fixtures/rails_unordered.log
|
64
65
|
- lib/request_log_analyzer/log_processor.rb
|
65
66
|
- lib/request_log_analyzer/tracker.rb
|
66
67
|
- lib/request_log_analyzer/filter.rb
|
67
|
-
- spec/fixtures/rails_unordered.log
|
68
68
|
- bin/request-log-analyzer
|
69
69
|
- request-log-analyzer.gemspec
|
70
70
|
- DESIGN.rdoc
|
@@ -103,8 +103,10 @@ files:
|
|
103
103
|
- spec/fixtures/test_language_combined.log
|
104
104
|
- lib/request_log_analyzer/aggregator/database_inserter.rb
|
105
105
|
- lib/request_log_analyzer/aggregator/summarizer.rb
|
106
|
+
- lib/request_log_analyzer/file_format/rack.rb
|
106
107
|
- lib/request_log_analyzer/file_format/rails.rb
|
107
108
|
- spec/fixtures/decompression.tar.gz
|
109
|
+
- spec/unit/tracker/traffic_tracker_spec.rb
|
108
110
|
- spec/unit/filter/field_filter_spec.rb
|
109
111
|
- spec/unit/database/base_class_spec.rb
|
110
112
|
- lib/request_log_analyzer/filter/timespan.rb
|
@@ -136,11 +138,12 @@ files:
|
|
136
138
|
- tasks/github-gem.rake
|
137
139
|
- spec/unit/database/database_spec.rb
|
138
140
|
- lib/request_log_analyzer/tracker/duration.rb
|
141
|
+
- lib/request_log_analyzer/tracker/traffic.rb
|
139
142
|
- lib/request_log_analyzer/file_format.rb
|
140
143
|
- spec/unit/aggregator/summarizer_spec.rb
|
144
|
+
- spec/fixtures/syslog_1x.log
|
141
145
|
- spec/fixtures/rails_22.log
|
142
146
|
- spec/fixtures/multiple_files_2.log
|
143
|
-
- spec/fixtures/syslog_1x.log
|
144
147
|
- LICENSE
|
145
148
|
- lib/request_log_analyzer/source/database_loader.rb
|
146
149
|
- spec/unit/tracker/frequency_tracker_spec.rb
|
@@ -191,6 +194,7 @@ test_files:
|
|
191
194
|
- spec/unit/file_format/amazon_s3_format_spec.rb
|
192
195
|
- spec/unit/controller/log_processor_spec.rb
|
193
196
|
- spec/unit/filter/filter_spec.rb
|
197
|
+
- spec/unit/tracker/traffic_tracker_spec.rb
|
194
198
|
- spec/unit/filter/field_filter_spec.rb
|
195
199
|
- spec/unit/database/base_class_spec.rb
|
196
200
|
- spec/unit/tracker/timespan_tracker_spec.rb
|