retained 0.2.1 → 0.2.2
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +9 -3
- data/README.md +33 -29
- data/lib/retained/tracker.rb +27 -4
- data/lib/retained/version.rb +1 -1
- data/retained.gemspec +6 -6
- data/test/tracker_spec.rb +60 -1
- metadata +6 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c5889b1f2f5d17776a5955402abf0bb72a628c1b
|
4
|
+
data.tar.gz: d0e5963b0989d0bdf257b4f6b1be551538dec580
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 62d5f2c52a6286581a4924a3f23df5918450345e40088e3c0d012b24a9a53d3c505cd0199d0c93fb4a590237ad6e80f42b64a3821844defed9f81d83cb59d703
|
7
|
+
data.tar.gz: 9279624e9cc42bf400675110ddecddf97cba3c8436880abe5e17e571a7a6b0a9f728b02bd2611cd632ce55184cbdf983320a94c72063d03e1920a41ed7f50819
|
data/CHANGELOG.md
CHANGED
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
@@ -1,3 +1,11 @@
|
|
1
|
+
GIT
|
2
|
+
remote: git://github.com/msaffitz/redis-bitops.git
|
3
|
+
revision: a82f1bdc809e843ff297bcaf4c7664d3fc461772
|
4
|
+
branch: watch_keys_fix
|
5
|
+
specs:
|
6
|
+
redis-bitops (0.2.1)
|
7
|
+
redis
|
8
|
+
|
1
9
|
GEM
|
2
10
|
remote: http://rubygems.org/
|
3
11
|
specs:
|
@@ -55,8 +63,6 @@ GEM
|
|
55
63
|
rdoc (3.12.2)
|
56
64
|
json (~> 1.4)
|
57
65
|
redis (3.1.0)
|
58
|
-
redis-bitops (0.2.1)
|
59
|
-
redis
|
60
66
|
simplecov (0.9.0)
|
61
67
|
docile (~> 1.1.0)
|
62
68
|
multi_json
|
@@ -78,7 +84,7 @@ DEPENDENCIES
|
|
78
84
|
minitest
|
79
85
|
rdoc (~> 3.12)
|
80
86
|
redis (~> 3.1.0)
|
81
|
-
redis-bitops
|
87
|
+
redis-bitops!
|
82
88
|
simplecov
|
83
89
|
timecop (~> 0.7.1)
|
84
90
|
yard (~> 0.7)
|
data/README.md
CHANGED
@@ -12,15 +12,15 @@ Retained makes it easy to track activity and retention at scale in daily, hourly
|
|
12
12
|
|
13
13
|
Use RubyGems to install Retained:
|
14
14
|
|
15
|
-
|
16
|
-
|
15
|
+
$ gem install retained
|
16
|
+
|
17
17
|
If you are using Bundler, you can also install it by adding it to your Gemfile:
|
18
18
|
|
19
|
-
|
20
|
-
|
19
|
+
gem 'retained'
|
20
|
+
|
21
21
|
Run `bundle install` to install.
|
22
22
|
|
23
|
-
## Basic Usage
|
23
|
+
## Basic Usage
|
24
24
|
|
25
25
|
Retained works by tracking whether an **entity** was active within a reporting interval for a specific **group**. Entities can be anything you wish to track activity for, and are identified by a unique identifier that you provide to Retained. Groups provide a scope for the entities activity. For example, groups allow you to track activity by a User (entity) for a specific feature of your product. Like entities, groups are identified by a unique identifier that you provide to Retained.
|
26
26
|
|
@@ -32,19 +32,23 @@ Retained's default settings are to connect to Redis at `redis://localhost:6379`,
|
|
32
32
|
|
33
33
|
To track an entity with an id of `entity_id` as active for the current day:
|
34
34
|
|
35
|
-
|
35
|
+
Retained.retain('entity_id')
|
36
36
|
|
37
37
|
To track an entity with an id of `entity_id` as active on another day:
|
38
38
|
|
39
|
-
|
40
|
-
|
39
|
+
Retained.retain('entity_id', period: Time.new(2013,10,1))
|
40
|
+
|
41
41
|
To query whether an entity was active on a given day:
|
42
42
|
|
43
|
-
|
43
|
+
Retained.active?('entity_id', period: Time.new(2014,3,21))
|
44
44
|
|
45
45
|
To determine the total number of active entities on a given day:
|
46
46
|
|
47
|
-
|
47
|
+
Retained.total_active(period: Time.new(2014,2,10))
|
48
|
+
|
49
|
+
To determine the total number of unique entities over a range:
|
50
|
+
|
51
|
+
Retained.unique_active(start: Time.new(2014,2,10), stop: Time.new(2014,2,13)
|
48
52
|
|
49
53
|
## Advanced Usage
|
50
54
|
|
@@ -52,38 +56,38 @@ To determine the total number of active entities on a given day:
|
|
52
56
|
|
53
57
|
Retained can be configured with an alternate Redis connection string as well as an alternate prefix for keys:
|
54
58
|
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
59
|
+
Retained.configure do |config|
|
60
|
+
config.redis = 'redis://example.org:6379'
|
61
|
+
config.prefix = 'my_prefix',
|
62
|
+
end
|
63
|
+
|
60
64
|
You can also configure groups to use an alternate reporting interval-- either daily, hourly, or by minute.
|
61
65
|
|
62
66
|
**Important!** Once set, a group's reporting interval **cannot** be changed.
|
63
67
|
|
64
68
|
For example, to configure a 'generated_report' group to use an hourly interval:
|
65
69
|
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
70
|
+
Retained.configure do |config|
|
71
|
+
config.group('generated_report).do |group|
|
72
|
+
group.reporting_interval = :hour
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
72
76
|
If you need to have more control over the Redis connection (i.e. to use Sentinel), you can directly provide an already established connection:
|
73
77
|
|
74
|
-
|
75
|
-
|
76
|
-
|
78
|
+
Retained.configure do |config|
|
79
|
+
config.redis_connection = Redis.new
|
80
|
+
end
|
77
81
|
|
78
82
|
### Non-Singleton Use
|
79
83
|
|
80
84
|
You can connect to multiple backends for Retained by instantiating a Retained tracker directly:
|
81
85
|
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
86
|
+
tracker = Retained::Tracker.new
|
87
|
+
tracker.configure do |config|
|
88
|
+
config.redis = 'redis://other.example.org:6379'
|
89
|
+
end
|
90
|
+
tracker.retain('entity_id)
|
87
91
|
|
88
92
|
## Contributing to retained
|
89
93
|
|
data/lib/retained/tracker.rb
CHANGED
@@ -16,21 +16,34 @@ module Retained
|
|
16
16
|
|
17
17
|
# Tracks the entity as active at the period, or now if no period
|
18
18
|
# is provided.
|
19
|
-
def retain(entity, group:
|
19
|
+
def retain(entity, group: 'default', period: Time.now)
|
20
20
|
index = entity_index(entity, group)
|
21
21
|
bitmap = config.redis_connection.sparse_bitmap(key_period(group, period))
|
22
22
|
bitmap[index] = true
|
23
23
|
end
|
24
24
|
|
25
|
-
# Total active entities in the period, or now if
|
25
|
+
# Total active entities in the period, or now if no period,
|
26
26
|
# is provided.
|
27
|
-
def total_active(group:
|
27
|
+
def total_active(group: 'default', period: Time.now)
|
28
28
|
bitmap = config.redis_connection.sparse_bitmap(key_period(group, period))
|
29
29
|
bitmap.bitcount
|
30
30
|
end
|
31
31
|
|
32
|
+
# Returns the total number of unique active entities between
|
33
|
+
# the start and end periods (inclusive), or now if no stop
|
34
|
+
# period is provided.
|
35
|
+
def unique_active(group: 'default', start:, stop: Time.now)
|
36
|
+
bitmaps = []
|
37
|
+
while ( start <= stop)
|
38
|
+
bitmaps << config.redis_connection.sparse_bitmap(key_period(group, start))
|
39
|
+
start += seconds_in_reporting_interval(config.group(group).reporting_interval)
|
40
|
+
end
|
41
|
+
return 0 if bitmaps.length == 0
|
42
|
+
bitmaps.inject { |uniques,bitmap| uniques | bitmap }.bitcount
|
43
|
+
end
|
44
|
+
|
32
45
|
# Returns true if the entity was active in the given period,
|
33
|
-
# or now if
|
46
|
+
# or now if no period is provided. If a group or an array of groups
|
34
47
|
# is provided activity will only be considered based on those groups.
|
35
48
|
def active?(entity, group: nil, period: Time.now)
|
36
49
|
group = [group] if group.is_a?(String)
|
@@ -73,5 +86,15 @@ LUA
|
|
73
86
|
period = period.utc.send("beginning_of_#{config.group(group).reporting_interval}")
|
74
87
|
"#{config.prefix}:#{group}:#{period.to_i}"
|
75
88
|
end
|
89
|
+
|
90
|
+
private
|
91
|
+
def seconds_in_reporting_interval(interval)
|
92
|
+
case(interval.to_sym)
|
93
|
+
when :day then 60*60*24
|
94
|
+
when :hour then 60*60
|
95
|
+
when :minute then 60
|
96
|
+
else fail "Unknown reporting interval: #{interval}"
|
97
|
+
end
|
98
|
+
end
|
76
99
|
end
|
77
100
|
end
|
data/lib/retained/version.rb
CHANGED
data/retained.gemspec
CHANGED
@@ -2,16 +2,16 @@
|
|
2
2
|
# DO NOT EDIT THIS FILE DIRECTLY
|
3
3
|
# Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
|
4
4
|
# -*- encoding: utf-8 -*-
|
5
|
-
# stub: retained 0.2.
|
5
|
+
# stub: retained 0.2.2 ruby lib
|
6
6
|
|
7
7
|
Gem::Specification.new do |s|
|
8
8
|
s.name = "retained"
|
9
|
-
s.version = "0.2.
|
9
|
+
s.version = "0.2.2"
|
10
10
|
|
11
11
|
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
12
12
|
s.require_paths = ["lib"]
|
13
13
|
s.authors = ["Mike Saffitz"]
|
14
|
-
s.date = "2014-
|
14
|
+
s.date = "2014-11-07"
|
15
15
|
s.description = "\n Easy tracking of activity and retention at scale in daily, hourly, or minute intervals\n using sparse Redis bitmaps.\n "
|
16
16
|
s.email = "m@saffitz.com"
|
17
17
|
s.extra_rdoc_files = [
|
@@ -48,7 +48,7 @@ Gem::Specification.new do |s|
|
|
48
48
|
|
49
49
|
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
|
50
50
|
s.add_runtime_dependency(%q<redis>, ["~> 3.1.0"])
|
51
|
-
s.add_runtime_dependency(%q<redis-bitops>, ["
|
51
|
+
s.add_runtime_dependency(%q<redis-bitops>, [">= 0"])
|
52
52
|
s.add_runtime_dependency(%q<activesupport>, [">= 0"])
|
53
53
|
s.add_development_dependency(%q<minitest>, [">= 0"])
|
54
54
|
s.add_development_dependency(%q<yard>, ["~> 0.7"])
|
@@ -59,7 +59,7 @@ Gem::Specification.new do |s|
|
|
59
59
|
s.add_development_dependency(%q<timecop>, ["~> 0.7.1"])
|
60
60
|
else
|
61
61
|
s.add_dependency(%q<redis>, ["~> 3.1.0"])
|
62
|
-
s.add_dependency(%q<redis-bitops>, ["
|
62
|
+
s.add_dependency(%q<redis-bitops>, [">= 0"])
|
63
63
|
s.add_dependency(%q<activesupport>, [">= 0"])
|
64
64
|
s.add_dependency(%q<minitest>, [">= 0"])
|
65
65
|
s.add_dependency(%q<yard>, ["~> 0.7"])
|
@@ -71,7 +71,7 @@ Gem::Specification.new do |s|
|
|
71
71
|
end
|
72
72
|
else
|
73
73
|
s.add_dependency(%q<redis>, ["~> 3.1.0"])
|
74
|
-
s.add_dependency(%q<redis-bitops>, ["
|
74
|
+
s.add_dependency(%q<redis-bitops>, [">= 0"])
|
75
75
|
s.add_dependency(%q<activesupport>, [">= 0"])
|
76
76
|
s.add_dependency(%q<minitest>, [">= 0"])
|
77
77
|
s.add_dependency(%q<yard>, ["~> 0.7"])
|
data/test/tracker_spec.rb
CHANGED
@@ -3,6 +3,8 @@ require 'securerandom'
|
|
3
3
|
require 'timecop'
|
4
4
|
Timecop.safe_mode = true
|
5
5
|
|
6
|
+
SECONDS_PER_HOUR = 60*60
|
7
|
+
SECONDS_PER_DAY = 24*SECONDS_PER_HOUR
|
6
8
|
|
7
9
|
describe Retained::Tracker do
|
8
10
|
let(:tracker) { Retained::Tracker.new }
|
@@ -28,6 +30,11 @@ describe Retained::Tracker do
|
|
28
30
|
tracker.config.redis_connection.sparse_bitmap("#{prefix}:group_a:1409356800")[0]
|
29
31
|
end
|
30
32
|
|
33
|
+
it 'retains using the "default" group' do
|
34
|
+
tracker.retain('entity_a')
|
35
|
+
tracker.groups.must_equal ['default']
|
36
|
+
end
|
37
|
+
|
31
38
|
it 'retain without a period tracks the entity for the current period' do
|
32
39
|
Timecop.freeze(Time.new(2014, 8, 29, 9, 03, 35)) do
|
33
40
|
tracker.retain('entity_a', group: 'group_a')
|
@@ -37,12 +44,13 @@ describe Retained::Tracker do
|
|
37
44
|
end
|
38
45
|
end
|
39
46
|
|
40
|
-
it 'total_active returns the number of active entities in the default group in the period' do
|
47
|
+
it 'total_active returns the number of active entities in the "default" group in the period' do
|
41
48
|
period = Time.new(2014, 8, 30, 10, 47, 35)
|
42
49
|
(count = rand(100)).times do |i|
|
43
50
|
tracker.retain("entity_#{i}", period: period)
|
44
51
|
end
|
45
52
|
tracker.total_active(period: period).must_equal count
|
53
|
+
tracker.total_active(group: "default", period: period).must_equal count
|
46
54
|
end
|
47
55
|
|
48
56
|
it 'total_active returns the number of active entities in the period' do
|
@@ -60,6 +68,57 @@ describe Retained::Tracker do
|
|
60
68
|
tracker.total_active(group: 'group_a').must_equal count
|
61
69
|
end
|
62
70
|
|
71
|
+
describe "unique_active" do
|
72
|
+
before(:each) do
|
73
|
+
tracker.configure do |config|
|
74
|
+
config.group('hour') { |g| g.reporting_interval = :hour }
|
75
|
+
config.group('minute') { |g| g.reporting_interval = :minute }
|
76
|
+
config.group('day') { |g| g.reporting_interval = :day}
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
it 'properly tracks uniques when the reporting_interval is day' do
|
81
|
+
Timecop.freeze do
|
82
|
+
(1..4).each { |e| tracker.retain(e, group: 'day', period: Time.now)}
|
83
|
+
(2..4).each { |e| tracker.retain(e, group: 'day', period: Time.now - SECONDS_PER_DAY)}
|
84
|
+
(2..5).each { |e| tracker.retain(e, group: 'day', period: Time.now - 2*SECONDS_PER_DAY)}
|
85
|
+
(1..2).each { |e| tracker.retain(e, group: 'day', period: Time.now - 3*SECONDS_PER_DAY)}
|
86
|
+
|
87
|
+
tracker.unique_active(group: 'day', start: Time.now - 2*SECONDS_PER_DAY).must_equal 5
|
88
|
+
tracker.unique_active(group: 'day', start: Time.now - 2*SECONDS_PER_DAY, stop: Time.now - SECONDS_PER_DAY).must_equal 4
|
89
|
+
tracker.unique_active(group: 'day', start: Time.now - 4*SECONDS_PER_DAY, stop: Time.now - 5*SECONDS_PER_DAY).must_equal 0
|
90
|
+
tracker.unique_active(group: 'day', start: Time.now - 3*SECONDS_PER_DAY, stop: Time.now - 3*SECONDS_PER_DAY).must_equal 2
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
it 'properly tracks uniques when the reporting_interval is hour' do
|
95
|
+
Timecop.freeze do
|
96
|
+
(1..2).each { |e| tracker.retain(e, group: 'hour', period: Time.now)}
|
97
|
+
(3..4).each { |e| tracker.retain(e, group: 'hour', period: Time.now - 3*SECONDS_PER_HOUR)}
|
98
|
+
(5..6).each { |e| tracker.retain(e, group: 'hour', period: Time.now - 4*SECONDS_PER_HOUR)}
|
99
|
+
(6..8).each { |e| tracker.retain(e, group: 'hour', period: Time.now - 6*SECONDS_PER_HOUR)}
|
100
|
+
|
101
|
+
tracker.unique_active(group: 'hour', start: Time.now - 4*SECONDS_PER_HOUR).must_equal 6
|
102
|
+
tracker.unique_active(group: 'hour', start: Time.now - 2*SECONDS_PER_HOUR, stop: Time.now - SECONDS_PER_HOUR).must_equal 0
|
103
|
+
# tracker.unique_active(group: 'hour', start: Time.now - 6*SECONDS_PER_HOUR, stop: Time.now - 4*SECONDS_PER_HOUR).must_equal 4
|
104
|
+
# tracker.unique_active(group: 'hour', start: Time.now - 6*SECONDS_PER_HOUR, stop: Time.now - 3*SECONDS_PER_HOUR).must_equal 7
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
it 'properly tracks uniques when the reporting_interval is minute' do
|
109
|
+
Timecop.freeze do
|
110
|
+
(1..2).each { |e| tracker.retain(e, group: 'minute', period: Time.now)}
|
111
|
+
(3..5).each { |e| tracker.retain(e, group: 'minute', period: Time.now - 2*60)}
|
112
|
+
(2..3).each { |e| tracker.retain(e, group: 'minute', period: Time.now - 4*60)}
|
113
|
+
(6..7).each { |e| tracker.retain(e, group: 'minute', period: Time.now - 5*60)}
|
114
|
+
|
115
|
+
tracker.unique_active(group: 'minute', start: Time.now - 5*60).must_equal 7
|
116
|
+
tracker.unique_active(group: 'minute', start: Time.now - 2*60, stop: Time.now).must_equal 5
|
117
|
+
tracker.unique_active(group: 'minute', start: Time.now - 5*60, stop: Time.now - 4*60).must_equal 4
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
63
122
|
describe 'active?' do
|
64
123
|
it 'returns true when the entity is active' do
|
65
124
|
Timecop.freeze do
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: retained
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Mike Saffitz
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2014-
|
11
|
+
date: 2014-11-07 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: redis
|
@@ -28,16 +28,16 @@ dependencies:
|
|
28
28
|
name: redis-bitops
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
30
30
|
requirements:
|
31
|
-
- - "
|
31
|
+
- - ">="
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version: 0
|
33
|
+
version: '0'
|
34
34
|
type: :runtime
|
35
35
|
prerelease: false
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
37
37
|
requirements:
|
38
|
-
- - "
|
38
|
+
- - ">="
|
39
39
|
- !ruby/object:Gem::Version
|
40
|
-
version: 0
|
40
|
+
version: '0'
|
41
41
|
- !ruby/object:Gem::Dependency
|
42
42
|
name: activesupport
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|