tabs 0.9.1 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +1 -0
- data/.ruby-version +1 -1
- data/.travis.yml +3 -0
- data/README.md +85 -5
- data/lib/tabs/config.rb +37 -2
- data/lib/tabs/metrics/counter.rb +14 -5
- data/lib/tabs/metrics/task.rb +8 -6
- data/lib/tabs/metrics/task/token.rb +25 -10
- data/lib/tabs/metrics/value.rb +35 -27
- data/lib/tabs/resolution.rb +26 -10
- data/lib/tabs/resolutionable.rb +36 -13
- data/lib/tabs/resolutions/day.rb +9 -1
- data/lib/tabs/resolutions/hour.rb +9 -1
- data/lib/tabs/resolutions/minute.rb +9 -1
- data/lib/tabs/resolutions/month.rb +9 -1
- data/lib/tabs/resolutions/week.rb +13 -7
- data/lib/tabs/resolutions/year.rb +9 -1
- data/lib/tabs/storage.rb +39 -17
- data/lib/tabs/tabs.rb +12 -4
- data/lib/tabs/version.rb +1 -1
- data/spec/lib/tabs/config_spec.rb +60 -0
- data/spec/lib/tabs/metrics/counter_spec.rb +44 -1
- data/spec/lib/tabs/{task_spec.rb → metrics/task_spec.rb} +31 -3
- data/spec/lib/tabs/metrics/value_spec.rb +36 -0
- data/spec/lib/tabs/resolution_spec.rb +26 -3
- data/spec/lib/tabs/resolutionable_spec.rb +53 -0
- data/spec/lib/tabs/resolutions/day_spec.rb +23 -0
- data/spec/lib/tabs/resolutions/hour_spec.rb +23 -0
- data/spec/lib/tabs/resolutions/minute_spec.rb +23 -0
- data/spec/lib/tabs/resolutions/month_spec.rb +23 -0
- data/spec/lib/tabs/resolutions/week_spec.rb +24 -0
- data/spec/lib/tabs/resolutions/year_spec.rb +23 -0
- data/spec/lib/tabs/storage_spec.rb +138 -0
- data/spec/lib/tabs_spec.rb +28 -1
- data/spec/spec_helper.rb +9 -1
- data/spec/support/custom_resolutions.rb +10 -2
- data/tabs.gemspec +6 -21
- metadata +48 -81
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 5925ee887e482c08b4ae405b4463162a90b41494
|
4
|
+
data.tar.gz: 878be73e42a4c32a58853c61385f71460bbafa60
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 531f0554860cb6143ee9d8cb29d7362d2111c63324a52ca5cc773f1767a80da58e2bae4680479abf791b024c185275334cc9b07fa7014dd459099af49e2cb4cd
|
7
|
+
data.tar.gz: 62bc646ae94b276fa7a089fe7e88824473b65e9080244be8a54a5ea0f5b064114226d06e82cbf918320c748bbedbfecc545280156c7b0542d2f3f4466f8c7be7
|
data/.gitignore
CHANGED
data/.ruby-version
CHANGED
@@ -1 +1 @@
|
|
1
|
-
|
1
|
+
2.0.0-p247
|
data/.travis.yml
CHANGED
data/README.md
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
# Tabs
|
4
4
|
|
5
|
-
Tabs is a redis-backed metrics tracker that supports counts, sums,
|
5
|
+
Tabs is a redis-backed metrics tracker for time-based events that supports counts, sums,
|
6
6
|
averages, and min/max, and task based stats sliceable by the minute, hour, day, week, month, and year.
|
7
7
|
|
8
8
|
## Installation
|
@@ -208,11 +208,15 @@ that's necessary is a module that conforms to the following protocol:
|
|
208
208
|
|
209
209
|
```ruby
|
210
210
|
module SecondResolution
|
211
|
-
|
211
|
+
include Tabs::Resolutionable
|
212
212
|
extend self
|
213
213
|
|
214
214
|
PATTERN = "%Y-%m-%d-%H-%M-%S"
|
215
215
|
|
216
|
+
def name
|
217
|
+
:seconds
|
218
|
+
end
|
219
|
+
|
216
220
|
def serialize(timestamp)
|
217
221
|
timestamp.strftime(PATTERN)
|
218
222
|
end
|
@@ -225,6 +229,10 @@ module SecondResolution
|
|
225
229
|
def from_seconds(s)
|
226
230
|
s / 1
|
227
231
|
end
|
232
|
+
|
233
|
+
def to_seconds
|
234
|
+
1
|
235
|
+
end
|
228
236
|
|
229
237
|
def add(timestamp, num_of_seconds)
|
230
238
|
timestamp + num_of_seconds.seconds
|
@@ -239,6 +247,8 @@ end
|
|
239
247
|
|
240
248
|
A little description on each of the above methods:
|
241
249
|
|
250
|
+
*`name`*: unique symbol used to reference registered resolution
|
251
|
+
|
242
252
|
*`serialize`*: converts the timestamp to a string. The return value
|
243
253
|
here will be used as part of the Redis key storing values associated
|
244
254
|
with a given metric.
|
@@ -249,6 +259,8 @@ into an actual DateTime value.
|
|
249
259
|
*`from_seconds`*: should return the number of periods in the given
|
250
260
|
number of seconds. For example, there are 60 seconds in a minute.
|
251
261
|
|
262
|
+
*`to_seconds`*: should return the number of seconds in '1' of these time periods. For example, there are 3600 seconds in an hour.
|
263
|
+
|
252
264
|
*`add`*: should add the number of seconds in the given resolution to the
|
253
265
|
supplied timestamp.
|
254
266
|
|
@@ -256,7 +268,7 @@ supplied timestamp.
|
|
256
268
|
For example, the week resolution returns the first hour of the first day
|
257
269
|
of the week.
|
258
270
|
|
259
|
-
*NOTE: If you're doing a custom resolution you should probably look into
|
271
|
+
*NOTE: If you're doing a custom resolution, you should probably look into
|
260
272
|
the code a bit.*
|
261
273
|
|
262
274
|
Once you have a module that conforms to the resolution protocol you need
|
@@ -264,11 +276,19 @@ to register it with Tabs. You can do this in one of two ways:
|
|
264
276
|
|
265
277
|
```ruby
|
266
278
|
# This call can be anywhere before you start using tabs
|
267
|
-
Tabs::Resolution.register(
|
279
|
+
Tabs::Resolution.register(SecondResolution)
|
268
280
|
|
269
281
|
# or, you can use the config block described below
|
270
282
|
```
|
271
283
|
|
284
|
+
#### Removing a Resolution
|
285
|
+
|
286
|
+
You can also remove any resolution (custom or built-in) by calling the `unregister_resolutions` method in the config block (see config section below). Or, you can remove manually by calling:
|
287
|
+
|
288
|
+
```ruby
|
289
|
+
Tabs::Resolution.unregister(:minute, :hour)
|
290
|
+
```
|
291
|
+
|
272
292
|
### Inspecting Metrics
|
273
293
|
|
274
294
|
You can list all metrics using `list_metrics`:
|
@@ -300,11 +320,31 @@ Tabs.drop_metric!("website-visits")
|
|
300
320
|
|
301
321
|
This will drop all recorded values for the metric so it may not be un-done...be careful.
|
302
322
|
|
323
|
+
To drop only a specific resolution for a metric, just call `Tabs#drop_resolution_for_metric!`
|
324
|
+
|
325
|
+
```ruby
|
326
|
+
Tabs.drop_resolution_for_metric!("website-visits", :minute)
|
327
|
+
```
|
328
|
+
|
303
329
|
Even more dangerous, you can drop all metrics...be very careful.
|
304
330
|
|
305
331
|
```ruby
|
306
332
|
Tabs.drop_all_metrics!
|
307
333
|
```
|
334
|
+
### Aging Out Old Metrics
|
335
|
+
|
336
|
+
You can use the expiration features to age out old metrics that may no longer be in your operational data set. For example, you may want to keep monthly or yearly data around but the minute or day level data isn't necessary past a certain date. You can set expirations for any resolution:
|
337
|
+
|
338
|
+
```ruby
|
339
|
+
Tabs.configure do |config|
|
340
|
+
config.set_expirations(minute: 1.day, day: 1.week)
|
341
|
+
end
|
342
|
+
```
|
343
|
+
|
344
|
+
The expiration date will start counting at the beginning at the end of the given resolution. Meaning that for a month resolution the given expiration time would start at the end of a given month. A month resolution metric recorded in January with an expiration of 2 weeks would expire after the 2nd week of February.
|
345
|
+
|
346
|
+
*NOTE: You cannot expire task metrics at this time, only counter and
|
347
|
+
values.*
|
308
348
|
|
309
349
|
### Configuration
|
310
350
|
|
@@ -318,6 +358,10 @@ Tabs.configure do |config|
|
|
318
358
|
|
319
359
|
# pass a config hash that will be passed to Redis.new
|
320
360
|
config.redis = { :host => 'localhost', :port => 6379 }
|
361
|
+
|
362
|
+
# pass a prefix that will be used in addition to the "tabs" prefix with Redis keys
|
363
|
+
# Example: "tabs:my_app:metric_name"
|
364
|
+
config.prefix = "my_app"
|
321
365
|
|
322
366
|
# override default decimal precision (5)
|
323
367
|
# affects stat averages and task completion rate
|
@@ -326,10 +370,31 @@ Tabs.configure do |config|
|
|
326
370
|
# registers a custom resolution
|
327
371
|
config.register_resolution :second, SecondResolution
|
328
372
|
|
373
|
+
# unregisters any resolution
|
374
|
+
config.unregister_resolutions(:minute, :hour)
|
375
|
+
|
376
|
+
# sets TTL for redis keys of specific resolutions
|
377
|
+
config.set_expirations({ minute: 1.hour, hour: 1.day })
|
378
|
+
|
329
379
|
end
|
330
380
|
```
|
331
381
|
|
332
|
-
|
382
|
+
#### Prefixing
|
383
|
+
|
384
|
+
Many applications use a single Redis instance for a number of uses:
|
385
|
+
background jobs, ephemeral data, Tabs, etc. To avoid key collisions,
|
386
|
+
and to make it easier to drop all of your tabs data without affecting
|
387
|
+
other parts of your system (or if more than one app shares the Redis
|
388
|
+
instance) you can prefix a given 'instance'.
|
389
|
+
|
390
|
+
Setting the prefix config option will cause all of the keys that tabs
|
391
|
+
stores to use this format:
|
392
|
+
|
393
|
+
```
|
394
|
+
tabs:#{prefix}:#{key}..."
|
395
|
+
```
|
396
|
+
|
397
|
+
## Change Log & Breaking Changes
|
333
398
|
|
334
399
|
### v0.6.0
|
335
400
|
|
@@ -361,6 +426,21 @@ statement to simulate a transaction. Value data that was recorded prior
|
|
361
426
|
to v0.8.2 will not be accessible in this or future versions so please
|
362
427
|
continue to use v0.8.1 or lower if that is an issue.
|
363
428
|
|
429
|
+
### v1.0.0
|
430
|
+
|
431
|
+
_WARNING: Version 1.0.0 is not compatible with previous versions of
|
432
|
+
Tabs_
|
433
|
+
|
434
|
+
We have made a number of changes related to hour metric keys are stored
|
435
|
+
in Redis. At this point we'll be following semantec versioning and will
|
436
|
+
support backwards compatability between major versions. In this release
|
437
|
+
we've added a number of major features:
|
438
|
+
|
439
|
+
* Metric expiration
|
440
|
+
* Key prefixes
|
441
|
+
* Support for unregistering resolutions
|
442
|
+
* A number of small numeric fixes
|
443
|
+
|
364
444
|
## Contributing
|
365
445
|
|
366
446
|
1. Fork it
|
data/lib/tabs/config.rb
CHANGED
@@ -22,8 +22,43 @@ module Tabs
|
|
22
22
|
@redis ||= Redis.new
|
23
23
|
end
|
24
24
|
|
25
|
-
def
|
26
|
-
|
25
|
+
def prefix=(arg)
|
26
|
+
@prefix = arg
|
27
|
+
end
|
28
|
+
|
29
|
+
def prefix
|
30
|
+
@prefix
|
31
|
+
end
|
32
|
+
|
33
|
+
def register_resolution(klass)
|
34
|
+
Tabs::Resolution.register(klass)
|
35
|
+
end
|
36
|
+
|
37
|
+
def unregister_resolutions(*resolutions)
|
38
|
+
Tabs::Resolution.unregister(resolutions)
|
39
|
+
end
|
40
|
+
|
41
|
+
def expiration_settings
|
42
|
+
@expiration_settings ||= {}
|
43
|
+
end
|
44
|
+
|
45
|
+
def set_expirations(resolution_hash)
|
46
|
+
resolution_hash.each do |resolution, expires_in_seconds|
|
47
|
+
raise Tabs::ResolutionMissingError.new(resolution) unless Tabs::Resolution.all.include? resolution
|
48
|
+
expiration_settings[resolution] = expires_in_seconds
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def expires?(resolution)
|
53
|
+
expiration_settings.has_key?(resolution)
|
54
|
+
end
|
55
|
+
|
56
|
+
def expires_in(resolution)
|
57
|
+
expiration_settings[resolution]
|
58
|
+
end
|
59
|
+
|
60
|
+
def reset_expirations
|
61
|
+
@expiration_settings = {}
|
27
62
|
end
|
28
63
|
|
29
64
|
end
|
data/lib/tabs/metrics/counter.rb
CHANGED
@@ -21,8 +21,8 @@ module Tabs
|
|
21
21
|
|
22
22
|
def stats(period, resolution)
|
23
23
|
timestamps = timestamp_range period, resolution
|
24
|
-
keys = timestamps.map do |
|
25
|
-
|
24
|
+
keys = timestamps.map do |timestamp|
|
25
|
+
storage_key(resolution, timestamp)
|
26
26
|
end
|
27
27
|
|
28
28
|
values = mget(*keys).map do |v|
|
@@ -43,12 +43,21 @@ module Tabs
|
|
43
43
|
del_by_prefix("stat:counter:#{key}")
|
44
44
|
end
|
45
45
|
|
46
|
+
def drop_by_resolution!(resolution)
|
47
|
+
del_by_prefix("stat:counter:#{key}:count:#{resolution}")
|
48
|
+
end
|
49
|
+
|
50
|
+
def storage_key(resolution, timestamp)
|
51
|
+
formatted_time = Tabs::Resolution.serialize(resolution, timestamp)
|
52
|
+
"stat:counter:#{key}:count:#{resolution}:#{formatted_time}"
|
53
|
+
end
|
54
|
+
|
46
55
|
private
|
47
56
|
|
48
57
|
def increment_resolution(resolution, timestamp)
|
49
|
-
|
50
|
-
|
51
|
-
|
58
|
+
store_key = storage_key(resolution, timestamp)
|
59
|
+
incr(store_key)
|
60
|
+
Tabs::Resolution.expire(resolution, store_key, timestamp)
|
52
61
|
end
|
53
62
|
|
54
63
|
def increment_total
|
data/lib/tabs/metrics/task.rb
CHANGED
@@ -37,7 +37,7 @@ module Tabs
|
|
37
37
|
matching_tokens = started_tokens.select { |token| completed_tokens.include? token }
|
38
38
|
completion_rate = (matching_tokens.size.to_f / range.size).round(Config.decimal_precision)
|
39
39
|
elapsed_times = matching_tokens.map { |t| t.time_elapsed(resolution) }
|
40
|
-
average_completion_time = (elapsed_times.sum) / matching_tokens.size
|
40
|
+
average_completion_time = matching_tokens.blank? ? 0.0 : (elapsed_times.sum) / matching_tokens.size
|
41
41
|
Stats.new(
|
42
42
|
started_tokens.size,
|
43
43
|
completed_tokens.size,
|
@@ -51,18 +51,20 @@ module Tabs
|
|
51
51
|
del_by_prefix("stat:task:#{key}")
|
52
52
|
end
|
53
53
|
|
54
|
+
def storage_key(resolution, timestamp, type)
|
55
|
+
formatted_time = Tabs::Resolution.serialize(resolution, timestamp)
|
56
|
+
"stat:task:#{key}:#{type}:#{resolution}:#{formatted_time}"
|
57
|
+
end
|
58
|
+
|
54
59
|
private
|
55
60
|
|
56
61
|
def tokens_for_period(range, resolution, type)
|
57
62
|
keys = keys_for_range(range, resolution, type)
|
58
|
-
|
63
|
+
smembers_all(*keys).compact.map(&:to_a).flatten.map { |t| Token.new(t, key) }
|
59
64
|
end
|
60
65
|
|
61
66
|
def keys_for_range(range, resolution, type)
|
62
|
-
range.map
|
63
|
-
formatted_time = Tabs::Resolution.serialize(resolution, date)
|
64
|
-
"stat:task:#{key}:#{type}:#{formatted_time}"
|
65
|
-
end
|
67
|
+
range.map { |timestamp| storage_key(resolution, timestamp, type) }
|
66
68
|
end
|
67
69
|
|
68
70
|
end
|
@@ -14,13 +14,13 @@ module Tabs
|
|
14
14
|
|
15
15
|
def start(timestamp=Time.now)
|
16
16
|
self.start_time = timestamp.utc
|
17
|
-
sadd(
|
17
|
+
sadd(tokens_storage_key, token)
|
18
18
|
Tabs::Resolution.all.each { |res| record_start(res, start_time) }
|
19
19
|
end
|
20
20
|
|
21
21
|
def complete(timestamp=Time.now)
|
22
22
|
self.complete_time = timestamp.utc
|
23
|
-
unless sismember(
|
23
|
+
unless sismember(tokens_storage_key, token)
|
24
24
|
raise UnstartedTaskMetricError.new("No task for metric '#{key}' was started with token '#{token}'")
|
25
25
|
end
|
26
26
|
Tabs::Resolution.all.each { |res| record_complete(res, complete_time) }
|
@@ -40,32 +40,47 @@ module Tabs
|
|
40
40
|
|
41
41
|
private
|
42
42
|
|
43
|
-
def
|
43
|
+
def storage_key(resolution, timestamp, type)
|
44
44
|
formatted_time = Tabs::Resolution.serialize(resolution, timestamp)
|
45
|
-
|
45
|
+
"stat:task:#{key}:#{type}:#{resolution}:#{formatted_time}"
|
46
|
+
end
|
47
|
+
|
48
|
+
def started_storage_key
|
49
|
+
"stat:task:#{key}:#{token}:started_time"
|
50
|
+
end
|
51
|
+
|
52
|
+
def completed_storage_key
|
53
|
+
"stat:task:#{key}:#{token}:completed_time"
|
54
|
+
end
|
55
|
+
|
56
|
+
def tokens_storage_key
|
57
|
+
"stat:task:#{key}:tokens"
|
58
|
+
end
|
59
|
+
|
60
|
+
def record_start(resolution, timestamp)
|
61
|
+
sadd(storage_key(resolution, timestamp, "started"), token)
|
46
62
|
end
|
47
63
|
|
48
64
|
def record_complete(resolution, timestamp)
|
49
|
-
|
50
|
-
sadd("stat:task:#{key}:completed:#{formatted_time}", token)
|
65
|
+
sadd(storage_key(resolution, timestamp, "completed"), token)
|
51
66
|
end
|
52
67
|
|
53
68
|
def start_time=(timestamp)
|
54
|
-
set(
|
69
|
+
set(started_storage_key, timestamp)
|
55
70
|
@start_time = timestamp
|
56
71
|
end
|
57
72
|
|
58
73
|
def start_time
|
59
|
-
Time.parse(get(
|
74
|
+
Time.parse(get(started_storage_key))
|
60
75
|
end
|
61
76
|
|
62
77
|
def complete_time=(timestamp)
|
63
|
-
set(
|
78
|
+
set(completed_storage_key, timestamp)
|
64
79
|
@complete_time = timestamp
|
65
80
|
end
|
66
81
|
|
67
82
|
def complete_time
|
68
|
-
Time.parse(get(
|
83
|
+
Time.parse(get(completed_storage_key))
|
69
84
|
end
|
70
85
|
|
71
86
|
end
|
data/lib/tabs/metrics/value.rb
CHANGED
@@ -13,23 +13,21 @@ module Tabs
|
|
13
13
|
def record(value, timestamp=Time.now)
|
14
14
|
timestamp.utc
|
15
15
|
Tabs::Resolution.all.each do |resolution|
|
16
|
-
|
17
|
-
|
18
|
-
|
16
|
+
store_key = storage_key(resolution, timestamp)
|
17
|
+
update_values(store_key, value)
|
18
|
+
Tabs::Resolution.expire(resolution, store_key, timestamp)
|
19
19
|
end
|
20
20
|
true
|
21
21
|
end
|
22
22
|
|
23
23
|
def stats(period, resolution)
|
24
24
|
timestamps = timestamp_range period, resolution
|
25
|
-
keys = timestamps.map do |
|
26
|
-
|
27
|
-
"stat:value:#{key}:data:#{formatted_time}"
|
25
|
+
keys = timestamps.map do |timestamp|
|
26
|
+
storage_key(resolution, timestamp)
|
28
27
|
end
|
29
28
|
|
30
29
|
values = mget(*keys).map do |v|
|
31
|
-
value = v.nil? ? default_value(0) : v
|
32
|
-
value = Hash[value.map { |k, i| [k, to_numeric(i)] }]
|
30
|
+
value = v.nil? ? default_value(0) : JSON.parse(v)
|
33
31
|
value["timestamp"] = timestamps.shift
|
34
32
|
value.with_indifferent_access
|
35
33
|
end
|
@@ -41,41 +39,51 @@ module Tabs
|
|
41
39
|
del_by_prefix("stat:value:#{key}")
|
42
40
|
end
|
43
41
|
|
42
|
+
def drop_by_resolution!(resolution)
|
43
|
+
del_by_prefix("stat:value:#{key}:data:#{resolution}")
|
44
|
+
end
|
45
|
+
|
46
|
+
def storage_key(resolution, timestamp)
|
47
|
+
formatted_time = Tabs::Resolution.serialize(resolution, timestamp)
|
48
|
+
"stat:value:#{key}:data:#{resolution}:#{formatted_time}"
|
49
|
+
end
|
50
|
+
|
44
51
|
private
|
45
52
|
|
46
53
|
def update_values(stat_key, value)
|
47
|
-
|
48
|
-
|
49
|
-
update_min(
|
50
|
-
update_max(
|
51
|
-
update_avg(
|
54
|
+
hash = get_current_hash(stat_key)
|
55
|
+
increment(hash, value)
|
56
|
+
update_min(hash, value)
|
57
|
+
update_max(hash, value)
|
58
|
+
update_avg(hash)
|
59
|
+
set(stat_key, JSON.generate(hash))
|
52
60
|
end
|
53
61
|
|
54
|
-
def
|
55
|
-
|
62
|
+
def get_current_hash(stat_key)
|
63
|
+
hash = get(stat_key)
|
64
|
+
return JSON.parse(hash) if hash
|
65
|
+
default_value
|
56
66
|
end
|
57
67
|
|
58
|
-
def
|
59
|
-
|
68
|
+
def increment(hash, value)
|
69
|
+
hash["count"] += 1
|
70
|
+
hash["sum"] += value.to_f
|
60
71
|
end
|
61
72
|
|
62
|
-
def update_min(
|
63
|
-
min =
|
64
|
-
hset(stat_key, "min", value) if value < min || min == 0
|
73
|
+
def update_min(hash, value)
|
74
|
+
hash["min"] = value.to_f if hash["min"].nil? || value.to_f < hash["min"]
|
65
75
|
end
|
66
76
|
|
67
|
-
def update_max(
|
68
|
-
max =
|
69
|
-
hset(stat_key, "max", value) if value > max || max == 0
|
77
|
+
def update_max(hash, value)
|
78
|
+
hash["max"] = value.to_f if hash["max"].nil? || value.to_f > hash["max"]
|
70
79
|
end
|
71
80
|
|
72
|
-
def update_avg(
|
73
|
-
avg = sum.to_f / count
|
74
|
-
hset(stat_key, "avg", avg)
|
81
|
+
def update_avg(hash)
|
82
|
+
hash["avg"] = hash["sum"].to_f / hash["count"]
|
75
83
|
end
|
76
84
|
|
77
85
|
def default_value(nil_value=nil)
|
78
|
-
{ "count" => 0, "min" => nil_value, "max" => nil_value, "sum" => 0, "avg" => 0 }
|
86
|
+
{ "count" => 0, "min" => nil_value, "max" => nil_value, "sum" => 0.0, "avg" => 0.0 }
|
79
87
|
end
|
80
88
|
|
81
89
|
end
|