simple_metrics 0.2.3 → 0.3.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. data/.travis.yml +3 -0
  2. data/README.markdown +16 -53
  3. data/Rakefile +17 -0
  4. data/bin/populate +26 -13
  5. data/bin/simple_metrics_server +4 -3
  6. data/bin/simple_metrics_web +11 -0
  7. data/config.ru +6 -0
  8. data/default_config.yml +34 -0
  9. data/lib/simple_metrics/app.rb +136 -0
  10. data/lib/simple_metrics/array_aggregation.rb +84 -0
  11. data/lib/simple_metrics/bucket.rb +74 -63
  12. data/lib/simple_metrics/configuration.rb +79 -0
  13. data/lib/simple_metrics/data_point/base.rb +59 -0
  14. data/lib/simple_metrics/data_point/counter.rb +13 -0
  15. data/lib/simple_metrics/data_point/event.rb +12 -0
  16. data/lib/simple_metrics/data_point/gauge.rb +13 -0
  17. data/lib/simple_metrics/data_point/timing.rb +12 -0
  18. data/lib/simple_metrics/data_point.rb +32 -136
  19. data/lib/simple_metrics/data_point_repository.rb +131 -0
  20. data/lib/simple_metrics/functions.rb +5 -5
  21. data/lib/simple_metrics/graph.rb +10 -14
  22. data/lib/simple_metrics/metric.rb +28 -0
  23. data/lib/simple_metrics/metric_repository.rb +54 -0
  24. data/lib/simple_metrics/public/css/bootstrap-responsive.min.css +12 -0
  25. data/lib/simple_metrics/public/css/bootstrap.min.css +689 -0
  26. data/lib/simple_metrics/public/css/graph.css +45 -0
  27. data/lib/simple_metrics/public/css/rickshaw.min.css +1 -0
  28. data/lib/simple_metrics/public/img/glyphicons-halflings-white.png +0 -0
  29. data/lib/simple_metrics/public/img/glyphicons-halflings.png +0 -0
  30. data/lib/simple_metrics/public/js/application.js +278 -0
  31. data/lib/simple_metrics/public/js/backbone-0.9.2.min.js +38 -0
  32. data/lib/simple_metrics/public/js/bootstrap.min.js +6 -0
  33. data/lib/simple_metrics/public/js/d3.v2.min.js +4 -0
  34. data/lib/simple_metrics/public/js/handlebars-1.0.0.beta.6.js +1550 -0
  35. data/lib/simple_metrics/public/js/jquery-1.7.1.min.js +4 -0
  36. data/lib/simple_metrics/public/js/rickshaw.min.js +1 -0
  37. data/lib/simple_metrics/public/js/underscore-1.3.1.min.js +31 -0
  38. data/lib/simple_metrics/repository.rb +34 -0
  39. data/lib/simple_metrics/udp_server.rb +81 -0
  40. data/lib/simple_metrics/update_aggregation.rb +62 -0
  41. data/lib/simple_metrics/value_aggregation.rb +63 -0
  42. data/lib/simple_metrics/version.rb +1 -1
  43. data/lib/simple_metrics/views/graph.erb +93 -0
  44. data/lib/simple_metrics/views/index.erb +0 -0
  45. data/lib/simple_metrics/views/layout.erb +119 -0
  46. data/lib/simple_metrics/views/show.erb +31 -0
  47. data/lib/simple_metrics.rb +19 -76
  48. data/simple_metrics.gemspec +6 -0
  49. data/spec/array_aggregation_spec.rb +51 -0
  50. data/spec/bucket_spec.rb +24 -62
  51. data/spec/data_point_repository_spec.rb +114 -0
  52. data/spec/data_point_spec.rb +1 -70
  53. data/spec/graph_spec.rb +2 -20
  54. data/spec/metric_repository_spec.rb +53 -0
  55. data/spec/spec_helper.rb +3 -3
  56. data/spec/value_aggregation_spec.rb +52 -0
  57. metadata +131 -24
  58. data/bin/simple_metrics_client +0 -64
  59. data/lib/simple_metrics/client.rb +0 -83
  60. data/lib/simple_metrics/mongo.rb +0 -48
  61. data/lib/simple_metrics/server.rb +0 -66
@@ -5,7 +5,7 @@ module SimpleMetrics
5
5
  class << self
6
6
 
7
7
  def all
8
- @@all ||= SimpleMetrics.buckets_config.map { |r| Bucket.new(r) }
8
+ @@all ||= SimpleMetrics.config.buckets.map { |r| Bucket.new(r) }
9
9
  end
10
10
 
11
11
  def first
@@ -17,44 +17,62 @@ module SimpleMetrics
17
17
  all[index]
18
18
  end
19
19
 
20
- def coarse_buckets
21
- Bucket.all.sort_by! { |r| r.seconds }[1..-1]
20
+ def flush_raw_data(data)
21
+ data_points = []
22
+ data.each do |str|
23
+ begin
24
+ data_points << DataPoint.parse(str)
25
+ rescue DataPoint::ParserError => e
26
+ SimpleMetrics.logger.debug "Invalid Data skipped: #{str}, #{e}"
27
+ end
28
+ end
29
+ flush_data_points(data_points, Time.now.utc.to_i)
22
30
  end
23
31
 
24
- def flush_data_points(data_points)
32
+ def flush_data_points(data_points, ts = nil)
25
33
  return if data_points.empty?
26
34
  SimpleMetrics.logger.info "#{Time.now} Flushing #{data_points.count} counters to MongoDB"
27
35
 
28
- ts = Time.now.utc.to_i
36
+ ts ||= Time.now.utc.to_i
29
37
  bucket = Bucket.first
30
- data_points.group_by { |data| data.name }.each_pair do |name,dps|
31
- data = DataPoint.aggregate(dps)
32
- bucket.save(data, ts)
33
- end
34
38
 
35
- self.aggregate_all(ts)
39
+ data_points.group_by { |dp| dp.name }.each_pair do |name,dps|
40
+ dp = ValueAggregation.aggregate(dps)
41
+ bucket.save(dp, ts)
42
+ update_metric(dp)
43
+ aggregate(dp)
44
+ end
36
45
  end
37
46
 
38
- def aggregate_all(ts)
39
- ts_bucket = self.first.ts_bucket(ts)
40
-
47
+ def aggregate(dp)
41
48
  coarse_buckets.each do |bucket|
42
- current_ts = bucket.ts_bucket(ts_bucket)
43
- previous_ts = bucket.previous_ts_bucket(ts_bucket)
44
- SimpleMetrics.logger.debug "Aggregating #{bucket.name} #{previous_ts}....#{current_ts} (#{humanized_timestamp(previous_ts)}..#{humanized_timestamp(current_ts)})"
45
-
46
- unless bucket.stats_exist_in_previous_ts?(previous_ts)
47
- data_points = self.first.find_all_in_ts_range(previous_ts, current_ts)
48
- data_points.group_by { |data| data.name }.each_pair do |name,dps|
49
- data = DataPoint.aggregate(dps)
50
- bucket.save(data, previous_ts)
51
- end
49
+ existing_dp = bucket.find_data_point_at_ts(dp.ts, dp.name)
50
+ if existing_dp
51
+ UpdateAggregation.aggregate(existing_dp, dp)
52
+ bucket.update(existing_dp, existing_dp.ts)
53
+ else
54
+ dp.sum = dp.value
55
+ dp.total = 1
56
+ bucket.save(dp, dp.ts)
52
57
  end
53
58
  end
54
59
  end
55
60
 
56
61
  private
57
62
 
63
+ def update_metric(dp)
64
+ metric = MetricRepository.find_one_by_name(dp.name)
65
+ if metric
66
+ MetricRepository.update(Metric.new(:name => dp.name, :total => metric.total + 1))
67
+ else
68
+ MetricRepository.save(Metric.new(:name => dp.name, :total => 1))
69
+ end
70
+ end
71
+
72
+ def coarse_buckets
73
+ Bucket.all.sort_by! { |r| r.seconds }[1..-1]
74
+ end
75
+
58
76
  def humanized_timestamp(ts)
59
77
  Time.at(ts).utc
60
78
  end
@@ -63,10 +81,10 @@ module SimpleMetrics
63
81
  attr_reader :name, :capped
64
82
 
65
83
  def initialize(attributes)
66
- @name = attributes[:name]
67
- @seconds = attributes[:seconds]
68
- @capped = attributes[:capped]
69
- @size = attributes[:size]
84
+ @name = attributes['name']
85
+ @seconds = attributes['seconds']
86
+ @capped = attributes['capped']
87
+ @size = attributes['size']
70
88
  end
71
89
 
72
90
  def seconds
@@ -78,7 +96,7 @@ module SimpleMetrics
78
96
  end
79
97
 
80
98
  def ts_bucket(ts)
81
- ts / seconds * seconds
99
+ (ts / seconds) * seconds
82
100
  end
83
101
 
84
102
  def next_ts_bucket(ts)
@@ -89,70 +107,59 @@ module SimpleMetrics
89
107
  ts_bucket(ts) - seconds
90
108
  end
91
109
 
92
- def find(id)
93
- mongo_result = mongo_coll.find_one({ :_id => id })
94
- DataPoint.create_from_db(mongo_result)
95
- end
96
-
97
- def find_all_by_name(name)
98
- mongo_result = mongo_coll.find({ :name => name }).to_a
99
- mongo_result.inject([]) { |result, a| result << DataPoint.create_from_db(a) }
100
- end
101
-
102
- def find_all_in_ts(ts)
103
- mongo_result = mongo_coll.find({ :ts => ts_bucket(ts) }).to_a
104
- mongo_result.inject([]) { |result, a| result << DataPoint.create_from_db(a) }
110
+ # TODO: only used in tests, do we need it?
111
+ def find_all_at_ts(ts)
112
+ repository.find_all_at_ts(ts_bucket(ts))
105
113
  end
106
114
 
107
- def find_all_in_ts_by_name(ts, name)
108
- mongo_result = mongo_coll.find({ :ts => ts_bucket(ts), :name => name }).to_a
109
- mongo_result.inject([]) { |result, a| result << DataPoint.create_from_db(a) }
115
+ def find_data_point_at_ts(ts, name)
116
+ repository.find_data_point_at_ts(ts_bucket(ts), name)
110
117
  end
111
118
 
119
+ # TODO: only used in tests, do we need it?
112
120
  def find_all_in_ts_range(from, to)
113
- mongo_result = mongo_coll.find({ :ts => { "$gte" => from, "$lte" => to }}).to_a
114
- mongo_result.inject([]) { |result, a| result << DataPoint.create_from_db(a) }
121
+ repository.find_all_in_ts_range(from, to)
115
122
  end
116
123
 
117
124
  def find_all_in_ts_range_by_name(from, to, name)
118
- mongo_result = mongo_coll.find({ :name => name, :ts => { "$gte" => from, "$lte" => to }}).to_a
119
- mongo_result.inject([]) { |result, a| result << DataPoint.create_from_db(a) }
125
+ repository.find_all_in_ts_range_by_name(from, to, name)
120
126
  end
121
127
 
122
128
  def find_all_in_ts_range_by_wildcard(from, to, target)
123
- target = target.gsub('.', '\.')
124
- target = target.gsub('*', '.*')
125
- mongo_result = mongo_coll.find({ :name => /#{target}/, :ts => { "$gte" => from, "$lte" => to } }).to_a
126
- mongo_result.inject([]) { |result, a| result << DataPoint.create_from_db(a) }
129
+ repository.find_all_in_ts_range_by_wildcard(from, to, target)
127
130
  end
128
131
 
129
- def find_all_in_ts_range_by_regexp(from, to, target)
130
- mongo_result = mongo_coll.find({ :name => /#{target}/, :ts => { "$gte" => from, "$lte" => to } }).to_a
131
- mongo_result.inject([]) { |result, a| result << DataPoint.create_from_db(a) }
132
+ # TODO: only used in tests, do we need it?
133
+ def data_points_exist_at_ts?(ts, name)
134
+ repository.count_for_name_at(ts, name) > 0
132
135
  end
133
136
 
137
+ # TODO: only used in tests, do we need it?
134
138
  def stats_exist_in_previous_ts?(ts)
135
- mongo_coll.find({ :ts => ts }).count > 0
139
+ repository.count_at(ts) > 0
136
140
  end
137
141
 
138
142
  def find_all_distinct_names
139
- mongo_coll.distinct(:name).to_a
143
+ repository.find_all_distinct_names
140
144
  end
141
145
 
142
- def save(stats, ts)
143
- stats.ts = ts_bucket(ts)
144
- result = mongo_coll.insert(stats.attributes)
145
- SimpleMetrics.logger.debug "SERVER: MongoDB - insert in #{name}: #{stats.inspect}, result: #{result}"
146
+ def save(dp, ts)
147
+ dp.ts = ts_bucket(ts)
148
+ repository.save(dp)
149
+ SimpleMetrics.logger.debug "SERVER: MongoDB - insert in #{name}: #{dp.inspect}"
146
150
  end
147
151
 
148
- def mongo_coll
149
- Mongo.collection(name)
152
+ def update(dp, ts)
153
+ dp.ts = ts_bucket(ts)
154
+ repository.update(dp, ts)
155
+ SimpleMetrics.logger.debug "SERVER: MongoDB - update in #{name}: #{dp.inspect}"
150
156
  end
151
157
 
152
158
  def capped?
153
159
  @capped == true
154
160
  end
155
161
 
162
+ # TODO refactor, move to graph.rb
156
163
  def fill_gaps(from, to, query_result)
157
164
  return query_result if query_result.nil? || query_result.size == 0
158
165
 
@@ -176,6 +183,10 @@ module SimpleMetrics
176
183
 
177
184
  private
178
185
 
186
+ def repository
187
+ DataPointRepository.for_retention(name)
188
+ end
189
+
179
190
  def each_ts(from, to)
180
191
  current_bucket_ts = ts_bucket(from)
181
192
  while (current_bucket_ts <= ts_bucket(to))
@@ -0,0 +1,79 @@
1
+ require 'yaml'
2
+
3
+ module SimpleMetrics
4
+ class Configuration
5
+
6
+ attr_reader :config
7
+
8
+ def initialize(hash = {}, &block)
9
+ @config = load_defaults.merge(hash)
10
+ end
11
+
12
+ def configure(hash = {}, &block)
13
+ yield self if block_given?
14
+ self
15
+ end
16
+
17
+ def db
18
+ @db ||= config['db']
19
+ end
20
+
21
+ def db=(db)
22
+ @db = db
23
+ end
24
+
25
+ def buckets
26
+ @buckets ||= config['buckets']
27
+ end
28
+
29
+ def buckets=(buckets)
30
+ @buckets = buckets
31
+ end
32
+
33
+ def server
34
+ @server ||= config['server']
35
+ end
36
+
37
+ def server=(server)
38
+ @server = server
39
+ end
40
+
41
+ def web
42
+ @web ||= config['web']
43
+ end
44
+
45
+ def web=(web)
46
+ @web = web
47
+ end
48
+
49
+ private
50
+
51
+ def load_defaults
52
+ @config = load_config
53
+ rescue Errno::ENOENT # not found error
54
+ logger.info "Creating initial config file: #{config_file}"
55
+ FileUtils.cp(default_config_file, config_file)
56
+ @config = load_config
57
+ end
58
+
59
+ def config_file
60
+ File.expand_path('~/.simple_metrics.conf')
61
+ end
62
+
63
+ def default_config_file
64
+ File.expand_path('../../../default_config.yml', __FILE__)
65
+ end
66
+
67
+ def load_config
68
+ YAML.load_file(config_file)
69
+ rescue ArgumentError => e
70
+ logger.error "Error parsing config file: #{e}"
71
+ rescue IOError => e
72
+ logger.error "Error reading config file: #{e}"
73
+ end
74
+
75
+ def logger
76
+ SimpleMetrics.logger
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,59 @@
1
+ # encoding: utf-8
2
+ module SimpleMetrics
3
+ module DataPoint
4
+ class Base
5
+
6
+ attr_accessor :name, :ts, :type, :value, :total, :sum
7
+ attr_reader :id
8
+
9
+ def initialize(attributes)
10
+ @id = attributes[:id]
11
+ @name = attributes[:name]
12
+ @value = attributes[:value]
13
+ @ts = attributes[:ts]
14
+ @sample_rate = attributes[:sample_rate]
15
+ @sum = attributes[:sum]
16
+ @total = attributes[:total]
17
+ end
18
+
19
+ def counter?
20
+ @type == 'c'
21
+ end
22
+
23
+ def gauge?
24
+ @type == 'g'
25
+ end
26
+
27
+ def timing?
28
+ @type == 'ms'
29
+ end
30
+
31
+ def event?
32
+ @type == 'ev'
33
+ end
34
+
35
+ def timestamp
36
+ ts
37
+ end
38
+
39
+ def value
40
+ @value.to_i if @value
41
+ end
42
+
43
+ def attributes
44
+ {
45
+ :name => @name,
46
+ :value => @value,
47
+ :ts => @ts,
48
+ :type => @type,
49
+ :total => @total,
50
+ :sum => @sum
51
+ }
52
+ end
53
+
54
+ def to_s
55
+ attributes.to_s
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,13 @@
1
+ module SimpleMetrics
2
+ module DataPoint
3
+ class Counter < Base
4
+
5
+ def initialize(attributes)
6
+ super(attributes)
7
+ @type = 'c'
8
+ @value = (@value.to_i || 1) * (1.0 / (@sample_rate || 1).to_f)
9
+ end
10
+
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+ module SimpleMetrics
2
+ module DataPoint
3
+ class Event < Base
4
+
5
+ def initialize(attributes)
6
+ super(attributes)
7
+ @type = 'ev'
8
+ end
9
+
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ module SimpleMetrics
2
+ module DataPoint
3
+ class Gauge < Base
4
+
5
+ def initialize(attributes)
6
+ super(attributes)
7
+ @type = 'g'
8
+ @value = (@value.to_i || 1) * (1.0 / (@sample_rate || 1).to_f)
9
+ end
10
+
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+ module SimpleMetrics
2
+ module DataPoint
3
+ class Timing < Base
4
+
5
+ def initialize(attributes)
6
+ super(attributes)
7
+ @type = 'ms'
8
+ end
9
+
10
+ end
11
+ end
12
+ end
@@ -1,10 +1,10 @@
1
- # encoding: utf-8
2
1
  module SimpleMetrics
3
-
4
- class DataPoint
2
+ module DataPoint
3
+ extend self
5
4
 
6
5
  class NonMatchingTypesError < Exception; end
7
6
  class ParserError < Exception; end
7
+ class UnknownTypeError < Exception; end
8
8
 
9
9
  # examples:
10
10
  # com.example.test1:1|c
@@ -14,149 +14,45 @@ module SimpleMetrics
14
14
  # com.example.test4:44|ms
15
15
  REGEXP = /^([\d\w_.]*):(-?[\d]*)\|(c|g|ms){1}(\|@([.\d]+))?$/i
16
16
 
17
- class << self
18
-
19
- def parse(str)
20
- if str =~ REGEXP
21
- name, value, type, sample_rate = $1, $2, $3, $5
22
- if type == "ms"
23
- # TODO: implement sample_rate handling
24
- create_timing(:name => name, :value => value)
25
- elsif type == "g"
26
- create_gauge(:name => name, :value => (value.to_i || 1) * (1.0 / (sample_rate || 1).to_f) )
27
- elsif type == "c"
28
- create_counter(:name => name, :value => (value.to_i || 1) * (1.0 / (sample_rate || 1).to_f) )
29
- end
30
- else
31
- raise ParserError, "Parser Error - Invalid Stat: #{str}"
32
- end
33
- end
34
-
35
- def create_counter(attributes)
36
- self.new(attributes.merge(:type => 'c'))
37
- end
38
-
39
- def create_gauge(attributes)
40
- self.new(attributes.merge(:type => 'g'))
41
- end
42
-
43
- def create_timing(attributes)
44
- self.new(attributes.merge(:type => 'ms'))
45
- end
46
-
47
- def aggregate(stats_array, name = nil)
48
- raise NonMatchingTypesError unless stats_array.group_by { |stats| stats.type }.size == 1
49
-
50
- result_stat = stats_array.first.dup
51
- result_stat.name = name if name
52
- if stats_array.first.counter?
53
- result_stat.value = stats_array.map { |stats| stats.value }.inject(0) { |result, value| result += value }
54
- result_stat
55
- elsif stats_array.first.gauge?
56
- total_value = stats_array.map { |stats| stats.value }.inject(0) { |result, value| result += value }
57
- result_stat.value = total_value / stats_array.size
58
- result_stat
59
- elsif stats_array.first.timing?
60
- # TODO implement timing aggregation
61
- elsif stats_array.first.event?
62
- # TODO implement event aggregation
63
- else
64
- raise ArgumentError, "Unknown data point type"
65
- end
66
- end
67
-
68
- def aggregate_array(stats_array, name = nil)
69
- raise NonMatchingTypesError unless stats_array.group_by { |stats| stats.type }.size == 1
70
-
71
- if stats_array.first.counter?
72
- tmp_hash = ts_hash_aggregated(stats_array) do |value1, value2|
73
- value1 + value2
74
- end
75
-
76
- result = []
77
- tmp_hash.each_pair do |key, value|
78
- result << self.new(:name => name, :ts => key, :value => value, :type => 'c')
79
- end
80
- result
81
- elsif stats_array.first.gauge?
82
- tmp_hash = ts_hash_aggregated(stats_array) do |value1, value2|
83
- (value1 + value2)/2
84
- end
85
-
86
- result = []
87
- tmp_hash.each_pair do |key, value|
88
- result << self.new(:name => name, :ts => key, :value => value, :type => 'g')
89
- end
90
- result
91
- elsif stats_array.first.timing?
92
- # TODO implement timing aggregation
93
- elsif stats_array.first.event?
94
- # TODO implement event aggregation
95
- else
96
- raise ArgumentError, "Unknown data point type"
97
- end
98
- end
99
-
100
- def create_from_db(attributes)
101
- self.new(:name => attributes["name"], :value => attributes["value"], :ts => attributes["ts"], :type => attributes["type"])
102
- end
103
-
104
- def ts_hash(query_result)
105
- query_result.inject({}) { |result, dp| result[dp.ts] = dp; result }
106
- end
107
-
108
- private
109
-
110
- def ts_hash_aggregated(data_points, &block)
111
- tmp = {}
112
- data_points.each do |dp|
113
- if tmp.key?(dp.ts)
114
- tmp[dp.ts] = block.call(tmp[dp.ts], dp.value)
115
- else
116
- tmp[dp.ts] = dp.value
117
- end
118
- end
119
- tmp
17
+ def parse(str)
18
+ if str =~ REGEXP
19
+ name, value, type, sample_rate = $1, $2, $3, $5
20
+ build(:name => name, :value => value, :type => type, :sample_rate => sample_rate)
21
+ else
22
+ raise ParserError, "Parser Error - Invalid data point: #{str}"
120
23
  end
121
24
  end
122
25
 
123
- attr_accessor :name, :ts, :type, :value
124
-
125
- def initialize(attributes)
126
- @name = attributes[:name]
127
- @value = attributes[:value]
128
- @ts = attributes[:ts]
129
- @type = attributes[:type]
130
- end
131
-
132
- def counter?
133
- type == 'c'
134
- end
135
-
136
- def gauge?
137
- type == 'g'
26
+ def build(attributes)
27
+ case attributes[:type]
28
+ when 'c'
29
+ DataPoint.create_counter(attributes)
30
+ when 'g'
31
+ Gauge.new(attributes)
32
+ when 'ms'
33
+ Timing.new(attributes)
34
+ when 'ev'
35
+ Event.new(attributes)
36
+ else
37
+ raise UnknownTypeError, "Unknown Type Error: #{attributes[:type]}"
38
+ end
138
39
  end
139
40
 
140
- def timing?
141
- type == 'ms'
41
+ def create_counter(attributes)
42
+ Counter.new(attributes)
142
43
  end
143
44
 
144
- def timestamp
145
- ts
45
+ def create_gauge(attributes)
46
+ Gauge.new(attributes)
146
47
  end
147
48
 
148
- def value
149
- @value.to_i if @value
49
+ def create_timing(attributes)
50
+ Timing.new(attributes)
150
51
  end
151
52
 
152
- def attributes
153
- {
154
- :name => name,
155
- :value => value,
156
- :ts => ts,
157
- :type => type
158
- }
53
+ def ts_hash(query_result)
54
+ query_result.inject({}) { |result, dp| result[dp.ts] = dp; result }
159
55
  end
160
-
56
+
161
57
  end
162
- end
58
+ end