scout_rails_proxy_proxy 1.0.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.
@@ -0,0 +1,76 @@
1
+ # Stores metrics in a file before sending them to the server. Two uses:
2
+ # 1. A centralized store for multiple Agent processes. This way, only 1 checkin is sent to Scout rather than 1 per-process.
3
+ # 2. Bundling up reports from multiple timeslices to make updates more efficent server-side.
4
+ #
5
+ # Metrics are stored in a Hash, where the keys are Time.to_i on the minute. When depositing data,
6
+ # metrics are either merged with an existing time or placed in a new key.
7
+ class ScoutRailsProxy::Layaway
8
+ attr_accessor :file
9
+ def initialize
10
+ @file = ScoutRailsProxy::LayawayFile.new
11
+ end
12
+
13
+ def deposit_and_deliver
14
+ new_data = ScoutRailsProxy::Agent.instance.store.metric_hash
15
+ controller_count = 0
16
+ new_data.each do |meta,stats|
17
+ if meta.metric_name =~ /\AController/
18
+ controller_count += stats.call_count
19
+ end
20
+ end
21
+ ScoutRailsProxy::Agent.instance.logger.debug "Depositing #{controller_count} requests into #{Time.at(slot).strftime("%m/%d/%y %H:%M:%S %z")} slot."
22
+
23
+ to_deliver = {}
24
+ file.read_and_write do |old_data|
25
+ old_data ||= Hash.new
26
+ # merge data
27
+ # if the previous minute has ended, its time to send those metrics
28
+ if old_data.any? and old_data[slot].nil?
29
+ to_deliver = old_data
30
+ old_data = Hash.new
31
+ elsif old_data.any?
32
+ ScoutRailsProxy::Agent.instance.logger.debug "Not yet time to deliver metrics for slot [#{Time.at(old_data.keys.sort.last).strftime("%m/%d/%y %H:%M:%S %z")}]"
33
+ else
34
+ ScoutRailsProxy::Agent.instance.logger.debug "There is no data in the layaway file to deliver."
35
+ end
36
+ old_data[slot]=ScoutRailsProxy::Agent.instance.store.merge_data_and_clear(old_data[slot] || Hash.new)
37
+ ScoutRailsProxy::Agent.instance.logger.debug "Saving the following #{old_data.size} time slots locally:"
38
+ old_data.each do |k,v|
39
+ controller_count = 0
40
+ new_data.each do |meta,stats|
41
+ if meta.metric_name =~ /\AController/
42
+ controller_count += stats.call_count
43
+ end
44
+ end
45
+ ScoutRailsProxy::Agent.instance.logger.debug "#{Time.at(k).strftime("%m/%d/%y %H:%M:%S %z")} => #{controller_count} requests"
46
+ end
47
+ old_data
48
+ end
49
+ to_deliver.any? ? validate_data(to_deliver) : {}
50
+ end
51
+
52
+ # Ensures the data we're sending to the server isn't stale.
53
+ # This can occur if the agent is collecting data, and app server goes down w/data in the local storage.
54
+ # When it is restarted later data will remain in local storage but it won't be for the current reporting interval.
55
+ #
56
+ # If the data is stale, an empty Hash is returned. Otherwise, the data from the most recent slot is returned.
57
+ def validate_data(data)
58
+ data = data.to_a.sort
59
+ now = Time.now
60
+ if (most_recent = data.first.first) < now.to_i - 2*60
61
+ ScoutRailsProxy::Agent.instance.logger.debug "Local Storage is stale (#{Time.at(most_recent).strftime("%m/%d/%y %H:%M:%S %z")}). Not sending data."
62
+ {}
63
+ else
64
+ data.first.last
65
+ end
66
+ rescue
67
+ ScoutRailsProxy::Agent.instance.logger.debug $!.message
68
+ ScoutRailsProxy::Agent.instance.logger.debug $!.backtrace
69
+ end
70
+
71
+ def slot
72
+ t = Time.now
73
+ t -= t.sec
74
+ t.to_i
75
+ end
76
+ end
@@ -0,0 +1,70 @@
1
+ # Logic for the serialized file access
2
+ class ScoutRailsProxy::LayawayFile
3
+ def path
4
+ "#{ScoutRailsProxy::Agent.instance.log_path}/scout_rails_proxy.db"
5
+ end
6
+
7
+ def dump(object)
8
+ Marshal.dump(object)
9
+ end
10
+
11
+ def load(dump)
12
+ if dump.size == 0
13
+ ScoutRailsProxy::Agent.instance.logger.debug("No data in layaway file.")
14
+ return nil
15
+ end
16
+ Marshal.load(dump)
17
+ rescue ArgumentError, TypeError => e
18
+ ScoutRailsProxy::Agent.instance.logger.debug("Error loading data from layaway file: #{e.inspect}")
19
+ ScoutRailsProxy::Agent.instance.logger.debug(e.backtrace.inspect)
20
+ nil
21
+ end
22
+
23
+ def read_and_write
24
+ File.open(path, File::RDWR | File::CREAT) do |f|
25
+ f.flock(File::LOCK_EX)
26
+ begin
27
+ result = (yield get_data(f))
28
+ f.rewind
29
+ f.truncate(0)
30
+ if result
31
+ write(f, dump(result))
32
+ end
33
+ ensure
34
+ f.flock(File::LOCK_UN)
35
+ end
36
+ end
37
+ rescue Errno::ENOENT, Exception => e
38
+ ScoutRailsProxy::Agent.instance.logger.error(e.message)
39
+ ScoutRailsProxy::Agent.instance.logger.debug(e.backtrace.split("\n"))
40
+ end
41
+
42
+ def get_data(f)
43
+ data = read_until_end(f)
44
+ result = load(data)
45
+ f.truncate(0)
46
+ result
47
+ end
48
+
49
+ def write(f, string)
50
+ result = 0
51
+ while (result < string.length)
52
+ result += f.write_nonblock(string)
53
+ end
54
+ rescue Errno::EAGAIN, Errno::EINTR
55
+ IO.select(nil, [f])
56
+ retry
57
+ end
58
+
59
+ def read_until_end(f)
60
+ contents = ""
61
+ while true
62
+ contents << f.read_nonblock(10_000)
63
+ end
64
+ rescue Errno::EAGAIN, Errno::EINTR
65
+ IO.select([f])
66
+ retry
67
+ rescue EOFError
68
+ contents
69
+ end
70
+ end
@@ -0,0 +1,34 @@
1
+ # Contains the meta information associated with a metric. Used to lookup Metrics in to Store's metric_hash.
2
+ class ScoutRailsProxy::MetricMeta
3
+ def initialize(metric_name, options = {})
4
+ @metric_name = metric_name
5
+ @metric_id = nil
6
+ @scope = Thread::current[:scout_sub_scope] || Thread::current[:scout_scope_name]
7
+ @desc = options[:desc]
8
+ @extra = {}
9
+ end
10
+ attr_accessor :metric_id, :metric_name
11
+ attr_accessor :scope
12
+ attr_accessor :client_id
13
+ attr_accessor :desc, :extra
14
+
15
+ # To avoid conflicts with different JSON libaries
16
+ def to_json(*a)
17
+ %Q[{"metric_id":#{metric_id || 'null'},"metric_name":#{metric_name.to_json},"scope":#{scope.to_json || 'null'}}]
18
+ end
19
+
20
+ def ==(o)
21
+ self.eql?(o)
22
+ end
23
+
24
+ def hash
25
+ h = metric_name.downcase.hash
26
+ h ^= scope.downcase.hash unless scope.nil?
27
+ h ^= desc.downcase.hash unless desc.nil?
28
+ h
29
+ end
30
+
31
+ def eql?(o)
32
+ self.class == o.class && metric_name.downcase.eql?(o.metric_name.downcase) && scope == o.scope && client_id == o.client_id && desc == o.desc
33
+ end
34
+ end # class MetricMeta
@@ -0,0 +1,49 @@
1
+ # Stats that are associated with each instrumented method.
2
+ class ScoutRailsProxy::MetricStats
3
+ attr_accessor :call_count
4
+ attr_accessor :min_call_time
5
+ attr_accessor :max_call_time
6
+ attr_accessor :total_call_time
7
+ attr_accessor :total_exclusive_time
8
+ attr_accessor :sum_of_squares
9
+
10
+ def initialize(scoped = false)
11
+ @scoped = scoped
12
+ self.call_count = 0
13
+ self.total_call_time = 0.0
14
+ self.total_exclusive_time = 0.0
15
+ self.min_call_time = 0.0
16
+ self.max_call_time = 0.0
17
+ self.sum_of_squares = 0.0
18
+ end
19
+
20
+ def update!(call_time,exclusive_time)
21
+ # If this metric is scoped inside another, use exclusive time for min/max and sum_of_squares. Non-scoped metrics
22
+ # (like controller actions) track the total call time.
23
+ t = (@scoped ? exclusive_time : call_time)
24
+ self.min_call_time = t if self.call_count == 0 or t < min_call_time
25
+ self.max_call_time = t if self.call_count == 0 or t > max_call_time
26
+ self.call_count +=1
27
+ self.total_call_time += call_time
28
+ self.total_exclusive_time += exclusive_time
29
+ self.sum_of_squares += (t * t)
30
+ self
31
+ end
32
+
33
+ # combines data from another MetricStats object
34
+ def combine!(other)
35
+ self.call_count += other.call_count
36
+ self.total_call_time += other.total_call_time
37
+ self.total_exclusive_time += other.total_exclusive_time
38
+ self.min_call_time = other.min_call_time if self.min_call_time.zero? or other.min_call_time < self.min_call_time
39
+ self.max_call_time = other.max_call_time if other.max_call_time > self.max_call_time
40
+ self.sum_of_squares += other.sum_of_squares
41
+ self
42
+ end
43
+
44
+ # To avoid conflicts with different JSON libaries handle JSON ourselves.
45
+ # Time-based metrics are converted to milliseconds from seconds.
46
+ def to_json(*a)
47
+ %Q[{"total_exclusive_time":#{total_exclusive_time*1000},"min_call_time":#{min_call_time*1000},"call_count":#{call_count},"sum_of_squares":#{sum_of_squares*1000},"total_call_time":#{total_call_time*1000},"max_call_time":#{max_call_time*1000}}]
48
+ end
49
+ end # class MetricStats
@@ -0,0 +1,18 @@
1
+ class ScoutRailsProxy::StackItem
2
+ attr_accessor :children_time
3
+ attr_reader :metric_name, :start_time
4
+
5
+ def initialize(metric_name)
6
+ @metric_name = metric_name
7
+ @start_time = Time.now
8
+ @children_time = 0
9
+ end
10
+
11
+ def ==(o)
12
+ self.eql?(o)
13
+ end
14
+
15
+ def eql?(o)
16
+ self.class == o.class && metric_name.eql?(o.metric_name)
17
+ end
18
+ end # class StackItem
@@ -0,0 +1,159 @@
1
+ # The store encapsolutes the logic that (1) saves instrumented data by Metric name to memory and (2) maintains a stack (just an Array)
2
+ # of instrumented methods that are being called. It's accessed via +ScoutRailsProxy::Agent.instance.store+.
3
+ class ScoutRailsProxy::Store
4
+ attr_accessor :metric_hash
5
+ attr_accessor :transaction_hash
6
+ attr_accessor :stack
7
+ attr_accessor :sample
8
+ attr_reader :transaction_sample_lock
9
+
10
+ def initialize
11
+ @metric_hash = Hash.new
12
+ # Stores aggregate metrics for the current transaction. When the transaction is finished, metrics
13
+ # are merged with the +metric_hash+.
14
+ @transaction_hash = Hash.new
15
+ @stack = Array.new
16
+ # ensure background thread doesn't manipulate transaction sample while the store is.
17
+ @transaction_sample_lock = Mutex.new
18
+ end
19
+
20
+ # Called when the last stack item completes for the current transaction to clear
21
+ # for the next run.
22
+ def reset_transaction!
23
+ Thread::current[:ignore_transaction] = nil
24
+ Thread::current[:scout_scope_name] = nil
25
+ @transaction_hash = Hash.new
26
+ @stack = Array.new
27
+ end
28
+
29
+ def ignore_transaction!
30
+ Thread::current[:ignore_transaction] = true
31
+ end
32
+
33
+ # Called at the start of Tracer#instrument:
34
+ # (1) Either finds an existing MetricStats object in the metric_hash or
35
+ # initialize a new one. An existing MetricStats object is present if this +metric_name+ has already been instrumented.
36
+ # (2) Adds a StackItem to the stack. This StackItem is returned and later used to validate the item popped off the stack
37
+ # when an instrumented code block completes.
38
+ def record(metric_name)
39
+ item = ScoutRailsProxy::StackItem.new(metric_name)
40
+ stack << item
41
+ item
42
+ end
43
+
44
+ def stop_recording(sanity_check_item, options={})
45
+ item = stack.pop
46
+ stack_empty = stack.empty?
47
+ # if ignoring the transaction, the item is popped but nothing happens.
48
+ if Thread::current[:ignore_transaction]
49
+ return
50
+ end
51
+ # unbalanced stack check - unreproducable cases have seen this occur. when it does, sets a Thread variable
52
+ # so we ignore further recordings. +Store#reset_transaction!+ resets this.
53
+ if item != sanity_check_item
54
+ ScoutRailsProxy::Agent.instance.logger.warn "Scope [#{Thread::current[:scout_scope_name]}] Popped off stack: #{item.inspect} Expected: #{sanity_check_item.inspect}. Aborting."
55
+ ignore_transaction!
56
+ return
57
+ end
58
+ duration = Time.now - item.start_time
59
+ if last=stack.last
60
+ last.children_time += duration
61
+ end
62
+ meta = ScoutRailsProxy::MetricMeta.new(item.metric_name, :desc => options[:desc])
63
+ meta.scope = nil if stack_empty
64
+
65
+ # add backtrace for slow calls ... how is exclusive time handled?
66
+ if duration > 0.5 and !stack_empty
67
+ meta.extra = {:backtrace => caller.find_all { |c| c =~ /\/app\//}}
68
+ end
69
+ stat = transaction_hash[meta] || ScoutRailsProxy::MetricStats.new(!stack_empty)
70
+
71
+ stat.update!(duration,duration-item.children_time)
72
+ transaction_hash[meta] = stat
73
+
74
+ # Uses controllers as the entry point for a transaction. Otherwise, stats are ignored.
75
+ if stack_empty and meta.metric_name.match(/\AController\//)
76
+ aggs=aggregate_calls(transaction_hash.dup,meta)
77
+ store_sample(options[:uri],transaction_hash.dup.merge(aggs),meta,stat)
78
+ # deep duplicate
79
+ duplicate = aggs.dup
80
+ duplicate.each_pair do |k,v|
81
+ duplicate[k.dup] = v.dup
82
+ end
83
+ merge_data(duplicate.merge({meta.dup => stat.dup})) # aggregrates + controller
84
+ end
85
+ end
86
+
87
+ # Returns the top-level category names used in the +metrics+ hash.
88
+ def categories(metrics)
89
+ cats = Set.new
90
+ metrics.keys.each do |meta|
91
+ next if meta.scope.nil? # ignore controller
92
+ if match=meta.metric_name.match(/\A([\w|\d]+)\//)
93
+ cats << match[1]
94
+ end
95
+ end # metrics.each
96
+ cats
97
+ end
98
+
99
+ # Takes a metric_hash of calls and generates aggregates for ActiveRecord and View calls.
100
+ def aggregate_calls(metrics,parent_meta)
101
+ categories = categories(metrics)
102
+ aggregates = {}
103
+ categories.each do |cat|
104
+ agg_meta=ScoutRailsProxy::MetricMeta.new("#{cat}/all")
105
+ agg_meta.scope = parent_meta.metric_name
106
+ agg_stats = ScoutRailsProxy::MetricStats.new
107
+ metrics.each do |meta,stats|
108
+ if meta.metric_name =~ /\A#{cat}\//
109
+ agg_stats.combine!(stats)
110
+ end
111
+ end # metrics.each
112
+ aggregates[agg_meta] = agg_stats unless agg_stats.call_count.zero?
113
+ end # categories.each
114
+ aggregates
115
+ end
116
+
117
+ # Stores the slowest transaction. This will be sent to the server.
118
+ def store_sample(uri,transaction_hash,parent_meta,parent_stat,options = {})
119
+ @transaction_sample_lock.synchronize do
120
+ if parent_stat.total_call_time >= 2 and (@sample.nil? or (@sample and parent_stat.total_call_time > @sample.total_call_time))
121
+ @sample = ScoutRailsProxy::TransactionSample.new(uri,parent_meta.metric_name,parent_stat.total_call_time,transaction_hash.dup)
122
+ end
123
+ end
124
+ end
125
+
126
+ # Finds or creates the metric w/the given name in the metric_hash, and updates the time. Primarily used to
127
+ # record sampled metrics. For instrumented methods, #record and #stop_recording are used.
128
+ #
129
+ # Options:
130
+ # :scope => If provided, overrides the default scope.
131
+ # :exclusive_time => Sets the exclusive time for the method. If not provided, uses +call_time+.
132
+ def track!(metric_name,call_time,options = {})
133
+ meta = ScoutRailsProxy::MetricMeta.new(metric_name)
134
+ meta.scope = options[:scope] if options.has_key?(:scope)
135
+ stat = metric_hash[meta] || ScoutRailsProxy::MetricStats.new
136
+ stat.update!(call_time,options[:exclusive_time] || call_time)
137
+ metric_hash[meta] = stat
138
+ end
139
+
140
+ # Combines old and current data
141
+ def merge_data(old_data)
142
+ old_data.each do |old_meta,old_stats|
143
+ if stats = metric_hash[old_meta]
144
+ metric_hash[old_meta] = stats.combine!(old_stats)
145
+ else
146
+ metric_hash[old_meta] = old_stats
147
+ end
148
+ end
149
+ metric_hash
150
+ end
151
+
152
+ # Merges old and current data, clears the current in-memory metric hash, and returns
153
+ # the merged data
154
+ def merge_data_and_clear(old_data)
155
+ merged = merge_data(old_data)
156
+ self.metric_hash = {}
157
+ merged
158
+ end
159
+ end # class Store
@@ -0,0 +1,105 @@
1
+ # Contains the methods that instrument blocks of code.
2
+ #
3
+ # When a code block is wrapped inside #instrument(metric_name):
4
+ # * The #instrument method pushes a StackItem onto Store#stack
5
+ # * When a code block is finished, #instrument pops the last item off the stack and verifies it's the StackItem
6
+ # we created earlier.
7
+ # * Once verified, the metrics for the recording session are merged into the in-memory Store#metric_hash. The current scope
8
+ # is also set for the metric (if Thread::current[:scout_scope_name] isn't nil).
9
+ module ScoutRailsProxy::Tracer
10
+ def self.included(klass)
11
+ klass.extend ClassMethods
12
+ end
13
+
14
+ module ClassMethods
15
+
16
+ # Use to trace a method call, possibly reporting slow transaction traces to Scout.
17
+ def trace(metric_name, options = {}, &block)
18
+ ScoutRailsProxy::Agent.instance.store.reset_transaction!
19
+ instrument(metric_name, options) do
20
+ Thread::current[:scout_scope_name] = metric_name
21
+ yield
22
+ Thread::current[:scout_scope_name] = nil
23
+ end
24
+ end
25
+
26
+ # Options:
27
+ # - :scope => If specified, sets the sub-scope for the metric. We allow additional scope level. This is used
28
+ # when rendering the transaction tree in the UI.
29
+ def instrument(metric_name, options={}, &block)
30
+ # don't instrument if (1) NOT inside a transaction and (2) NOT a Controller metric.
31
+ if !Thread::current[:scout_scope_name] and metric_name !~ /\AController\//
32
+ ScoutRailsProxy::Agent.instance.logger.debug "Not instrumenting [#{metric_name}] - no scope."
33
+ return yield
34
+ end
35
+ if options.delete(:scope)
36
+ Thread::current[:scout_sub_scope] = metric_name
37
+ end
38
+ stack_item = ScoutRailsProxy::Agent.instance.store.record(metric_name)
39
+ begin
40
+ yield
41
+ ensure
42
+ Thread::current[:scout_sub_scope] = nil if Thread::current[:scout_sub_scope] == metric_name
43
+ ScoutRailsProxy::Agent.instance.store.stop_recording(stack_item,options)
44
+ end
45
+ end
46
+
47
+ def instrument_method(method,options = {})
48
+ ScoutRailsProxy::Agent.instance.logger.info "Instrumenting #{method}"
49
+ metric_name = options[:metric_name] || default_metric_name(method)
50
+ return if !instrumentable?(method) or instrumented?(method,metric_name)
51
+ class_eval instrumented_method_string(method, {:metric_name => metric_name, :scope => options[:scope]}), __FILE__, __LINE__
52
+
53
+ alias_method _uninstrumented_method_name(method, metric_name), method
54
+ alias_method method, _instrumented_method_name(method, metric_name)
55
+ end
56
+
57
+ private
58
+
59
+ def instrumented_method_string(method, options)
60
+ klass = (self === Module) ? "self" : "self.class"
61
+ "def #{_instrumented_method_name(method, options[:metric_name])}(*args, &block)
62
+ result = #{klass}.instrument(\"#{options[:metric_name]}\",{:scope => #{options[:scope] || false}}) do
63
+ #{_uninstrumented_method_name(method, options[:metric_name])}(*args, &block)
64
+ end
65
+ result
66
+ end"
67
+ end
68
+
69
+ # The method must exist to be instrumented.
70
+ def instrumentable?(method)
71
+ exists = method_defined?(method) || private_method_defined?(method)
72
+ ScoutRailsProxy::Agent.instance.logger.warn "The method [#{self.name}##{method}] does not exist and will not be instrumented" unless exists
73
+ exists
74
+ end
75
+
76
+ # +True+ if the method is already instrumented.
77
+ def instrumented?(method,metric_name)
78
+ instrumented = method_defined?(_instrumented_method_name(method, metric_name))
79
+ ScoutRailsProxy::Agent.instance.logger.warn "The method [#{self.name}##{method}] has already been instrumented" if instrumented
80
+ instrumented
81
+ end
82
+
83
+ def default_metric_name(method)
84
+ "Custom/#{self.name}/#{method.to_s}"
85
+ end
86
+
87
+ # given a method and a metric, this method returns the
88
+ # untraced alias of the method name
89
+ def _uninstrumented_method_name(method, metric_name)
90
+ "#{_sanitize_name(method)}_without_scout_instrument_#{_sanitize_name(metric_name)}"
91
+ end
92
+
93
+ # given a method and a metric, this method returns the traced
94
+ # alias of the method name
95
+ def _instrumented_method_name(method, metric_name)
96
+ name = "#{_sanitize_name(method)}_with_scout_instrument_#{_sanitize_name(metric_name)}"
97
+ end
98
+
99
+ # Method names like +any?+ or +replace!+ contain a trailing character that would break when
100
+ # eval'd as ? and ! aren't allowed inside method names.
101
+ def _sanitize_name(name)
102
+ name.to_s.tr_s('^a-zA-Z0-9', '_')
103
+ end
104
+ end # ClassMethods
105
+ end # module Tracer