retained 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.document +5 -0
- data/Gemfile +15 -0
- data/Gemfile.lock +84 -0
- data/LICENSE.txt +20 -0
- data/README.md +94 -0
- data/Rakefile +49 -0
- data/lib/retained/configuration.rb +51 -0
- data/lib/retained/group_configuration.rb +38 -0
- data/lib/retained/redis_connection.rb +15 -0
- data/lib/retained/tracker.rb +77 -0
- data/lib/retained/version.rb +10 -0
- data/lib/retained.rb +13 -0
- data/retained.gemspec +84 -0
- data/test/configuration_spec.rb +19 -0
- data/test/group_configuration_spec.rb +20 -0
- data/test/helper.rb +32 -0
- data/test/tracker_spec.rb +106 -0
- metadata +203 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 4a7991fa6200e314740535ab1bdb2291368f9ade
|
4
|
+
data.tar.gz: 7b14b392c420a4ddea336837024812995f341ccb
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 7377288f5071fb2451156242d399a4b5d72c29ff88111c453dfc5951fb000d81acb363fd84150fa1b1db20a83024b566d641985999100164f34ff4a8f7837609
|
7
|
+
data.tar.gz: 90f1c51c1ee2c9da74490d226b1eaa86b64710053b997e4e50a09906a51631f2dd5e0f2a4e7a37f86d155f55cca4cb40b24dc6be66735f5fb4dee45f2f42b0ff
|
data/.document
ADDED
data/Gemfile
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
source 'http://rubygems.org'
|
2
|
+
|
3
|
+
gem 'redis', '~> 3.1.0'
|
4
|
+
gem 'redis-bitops', '~> 0.2.1'
|
5
|
+
gem 'activesupport', '~> 4.1.5'
|
6
|
+
|
7
|
+
group :development do
|
8
|
+
gem 'minitest', '>= 0'
|
9
|
+
gem 'yard', '~> 0.7'
|
10
|
+
gem 'rdoc', '~> 3.12'
|
11
|
+
gem 'bundler', '~> 1.0'
|
12
|
+
gem 'jeweler', '~> 2.0.1'
|
13
|
+
gem 'simplecov', '>= 0'
|
14
|
+
gem 'timecop', '~> 0.7.1'
|
15
|
+
end
|
data/Gemfile.lock
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
GEM
|
2
|
+
remote: http://rubygems.org/
|
3
|
+
specs:
|
4
|
+
activesupport (4.1.5)
|
5
|
+
i18n (~> 0.6, >= 0.6.9)
|
6
|
+
json (~> 1.7, >= 1.7.7)
|
7
|
+
minitest (~> 5.1)
|
8
|
+
thread_safe (~> 0.1)
|
9
|
+
tzinfo (~> 1.1)
|
10
|
+
addressable (2.3.6)
|
11
|
+
builder (3.2.2)
|
12
|
+
descendants_tracker (0.0.4)
|
13
|
+
thread_safe (~> 0.3, >= 0.3.1)
|
14
|
+
docile (1.1.5)
|
15
|
+
faraday (0.9.0)
|
16
|
+
multipart-post (>= 1.2, < 3)
|
17
|
+
git (1.2.7)
|
18
|
+
github_api (0.12.1)
|
19
|
+
addressable (~> 2.3)
|
20
|
+
descendants_tracker (~> 0.0.4)
|
21
|
+
faraday (~> 0.8, < 0.10)
|
22
|
+
hashie (>= 3.2)
|
23
|
+
multi_json (>= 1.7.5, < 2.0)
|
24
|
+
nokogiri (~> 1.6.3)
|
25
|
+
oauth2
|
26
|
+
hashie (3.3.1)
|
27
|
+
highline (1.6.21)
|
28
|
+
i18n (0.6.11)
|
29
|
+
jeweler (2.0.1)
|
30
|
+
builder
|
31
|
+
bundler (>= 1.0)
|
32
|
+
git (>= 1.2.5)
|
33
|
+
github_api
|
34
|
+
highline (>= 1.6.15)
|
35
|
+
nokogiri (>= 1.5.10)
|
36
|
+
rake
|
37
|
+
rdoc
|
38
|
+
json (1.8.1)
|
39
|
+
jwt (1.0.0)
|
40
|
+
mini_portile (0.6.0)
|
41
|
+
minitest (5.4.0)
|
42
|
+
multi_json (1.10.1)
|
43
|
+
multi_xml (0.5.5)
|
44
|
+
multipart-post (2.0.0)
|
45
|
+
nokogiri (1.6.3.1)
|
46
|
+
mini_portile (= 0.6.0)
|
47
|
+
oauth2 (1.0.0)
|
48
|
+
faraday (>= 0.8, < 0.10)
|
49
|
+
jwt (~> 1.0)
|
50
|
+
multi_json (~> 1.3)
|
51
|
+
multi_xml (~> 0.5)
|
52
|
+
rack (~> 1.2)
|
53
|
+
rack (1.5.2)
|
54
|
+
rake (10.3.2)
|
55
|
+
rdoc (3.12.2)
|
56
|
+
json (~> 1.4)
|
57
|
+
redis (3.1.0)
|
58
|
+
redis-bitops (0.2.1)
|
59
|
+
redis
|
60
|
+
simplecov (0.9.0)
|
61
|
+
docile (~> 1.1.0)
|
62
|
+
multi_json
|
63
|
+
simplecov-html (~> 0.8.0)
|
64
|
+
simplecov-html (0.8.0)
|
65
|
+
thread_safe (0.3.4)
|
66
|
+
timecop (0.7.1)
|
67
|
+
tzinfo (1.1.0)
|
68
|
+
thread_safe (~> 0.1)
|
69
|
+
yard (0.8.7.4)
|
70
|
+
|
71
|
+
PLATFORMS
|
72
|
+
ruby
|
73
|
+
|
74
|
+
DEPENDENCIES
|
75
|
+
activesupport (~> 4.1.5)
|
76
|
+
bundler (~> 1.0)
|
77
|
+
jeweler (~> 2.0.1)
|
78
|
+
minitest
|
79
|
+
rdoc (~> 3.12)
|
80
|
+
redis (~> 3.1.0)
|
81
|
+
redis-bitops (~> 0.2.1)
|
82
|
+
simplecov
|
83
|
+
timecop (~> 0.7.1)
|
84
|
+
yard (~> 0.7)
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2014 Mike Saffitz
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,94 @@
|
|
1
|
+
# Retained: Activity & Retention Tracking at Scale
|
2
|
+
|
3
|
+
Retained makes it easy to track activity and retention at scale in daily, hourly, or minute intervals using sparse Redis bitmaps.
|
4
|
+
|
5
|
+
|
6
|
+
## Requirements
|
7
|
+
|
8
|
+
* Ruby 2.0.0 or above
|
9
|
+
* Redis
|
10
|
+
|
11
|
+
## Installing Retained
|
12
|
+
|
13
|
+
Use RubyGems to install Retained:
|
14
|
+
|
15
|
+
$ gem install retained
|
16
|
+
|
17
|
+
If you are using Bundler, you can also install it by adding it to your Gemfile:
|
18
|
+
|
19
|
+
gem 'retained'
|
20
|
+
|
21
|
+
Run `bundle install` to install.
|
22
|
+
|
23
|
+
## Basic Usage
|
24
|
+
|
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
|
+
|
27
|
+
### Retained Defaults
|
28
|
+
|
29
|
+
Retained's default settings are to connect to Redis at `redis://localhost:6379`, to prefix all keys with `retained`, and to track activity within the `default` group at a daily interval. **Note:** All dates are stored internally as UTC.
|
30
|
+
|
31
|
+
### Examples
|
32
|
+
|
33
|
+
To track an entity with an id of `entity_id` as active for the current day:
|
34
|
+
|
35
|
+
Retained.retain('entity_id')
|
36
|
+
|
37
|
+
To track an entity with an id of `entity_id` as active on another day:
|
38
|
+
|
39
|
+
Retained.retain('entity_id', period: Time.new(2013,10,1))
|
40
|
+
|
41
|
+
To query whether an entity was active on a given day:
|
42
|
+
|
43
|
+
Retained.active?('entity_id', period: Time.new(2014,3,21))
|
44
|
+
|
45
|
+
To determine the total number of active entities on a given day:
|
46
|
+
|
47
|
+
Retained.total_active(period: Time.new(2014,2,10))
|
48
|
+
|
49
|
+
## Advanced Usage
|
50
|
+
|
51
|
+
### Configuration
|
52
|
+
|
53
|
+
Retained can be configured with an alternate Redis connection string as well as an alternate prefix for keys:
|
54
|
+
|
55
|
+
Retained.configure do |config|
|
56
|
+
config.redis = 'redis://example.org:6379'
|
57
|
+
config.prefix = 'my_prefix',
|
58
|
+
end
|
59
|
+
|
60
|
+
You can also configure groups to use an alternate reporting interval-- either daily, hourly, or by minute.
|
61
|
+
|
62
|
+
**Important!** Once set, a group's reporting interval **cannot** be changed.
|
63
|
+
|
64
|
+
For example, to configure a 'generated_report' group to use an hourly interval:
|
65
|
+
|
66
|
+
Retained.configure do |config|
|
67
|
+
config.group('generated_report).do |group|
|
68
|
+
group.reporting_interval = :hour
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
### Non-Singleton Use
|
73
|
+
|
74
|
+
You can connect to multiple backends for Retained by instantiating a Retained tracker directly:
|
75
|
+
|
76
|
+
tracker = Retained::Tracker.new
|
77
|
+
tracker.configure do |config|
|
78
|
+
config.redis = 'redis://other.example.org:6379'
|
79
|
+
end
|
80
|
+
tracker.retain('entity_id)
|
81
|
+
|
82
|
+
## Contributing to retained
|
83
|
+
|
84
|
+
* Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet.
|
85
|
+
* Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it.
|
86
|
+
* Fork the project.
|
87
|
+
* Start a feature/bugfix branch.
|
88
|
+
* Commit and push until you are happy with your contribution.
|
89
|
+
* Make sure to add tests for it. This is important so I don't break it in a future version unintentionally.
|
90
|
+
* Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it.
|
91
|
+
|
92
|
+
|
93
|
+
---
|
94
|
+
Copyright (c) 2014 Mike Saffitz. See LICENSE.txt for further details.
|
data/Rakefile
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
require 'bundler'
|
5
|
+
begin
|
6
|
+
Bundler.setup(:default, :development)
|
7
|
+
rescue Bundler::BundlerError => e
|
8
|
+
$stderr.puts e.message
|
9
|
+
$stderr.puts 'Run `bundle install` to install missing gems'
|
10
|
+
exit e.status_code
|
11
|
+
end
|
12
|
+
require 'rake'
|
13
|
+
|
14
|
+
require 'jeweler'
|
15
|
+
require './lib/retained/version.rb'
|
16
|
+
Jeweler::Tasks.new do |gem|
|
17
|
+
# gem is a Gem::Specification... see http://guides.rubygems.org/specification-reference/ for more options
|
18
|
+
gem.name = 'retained'
|
19
|
+
gem.homepage = 'http://github.com/msaffitz/retained'
|
20
|
+
gem.license = 'MIT'
|
21
|
+
gem.summary = %Q{Activity & Retention Tracking at Scale}
|
22
|
+
gem.description = %Q{
|
23
|
+
Easy tracking of activity and retention at scale in daily, hourly, or minute intervals
|
24
|
+
using sparse Redis bitmaps.
|
25
|
+
}
|
26
|
+
gem.email = 'm@saffitz.com'
|
27
|
+
gem.authors = ['Mike Saffitz']
|
28
|
+
gem.version = Retained::Version::STRING
|
29
|
+
# dependencies defined in Gemfile
|
30
|
+
end
|
31
|
+
Jeweler::RubygemsDotOrgTasks.new
|
32
|
+
|
33
|
+
require 'rake/testtask'
|
34
|
+
Rake::TestTask.new(:test) do |test|
|
35
|
+
test.libs << 'lib' << 'test'
|
36
|
+
test.pattern = 'test/**/*_spec.rb'
|
37
|
+
test.verbose = true
|
38
|
+
end
|
39
|
+
|
40
|
+
desc 'Code coverage detail'
|
41
|
+
task :simplecov do
|
42
|
+
ENV['COVERAGE'] = 'true'
|
43
|
+
Rake::Task['test'].execute
|
44
|
+
end
|
45
|
+
|
46
|
+
task :default => :test
|
47
|
+
|
48
|
+
require 'yard'
|
49
|
+
YARD::Rake::YardocTask.new
|
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'retained/redis_connection'
|
2
|
+
require 'retained/group_configuration'
|
3
|
+
|
4
|
+
module Retained
|
5
|
+
class Configuration
|
6
|
+
attr_accessor :redis
|
7
|
+
attr_accessor :prefix
|
8
|
+
attr_reader :default_group
|
9
|
+
attr_reader :group_configs
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
@redis = { url: 'redis://localhost:6379' }
|
13
|
+
@prefix = 'retained'
|
14
|
+
@default_group = 'default'
|
15
|
+
@group_configs = {}
|
16
|
+
end
|
17
|
+
|
18
|
+
# Retrieve the configuration for the group. A block may be provided
|
19
|
+
# to configure the group.
|
20
|
+
def group(group)
|
21
|
+
@group_configs[group.to_s] ||= fetch_group_configuration(group)
|
22
|
+
|
23
|
+
if block_given?
|
24
|
+
yield(@group_configs[group.to_s])
|
25
|
+
save_group_configuration(group, @group_configs[group.to_s])
|
26
|
+
else
|
27
|
+
@group_configs[group.to_s].set_defaults
|
28
|
+
end
|
29
|
+
|
30
|
+
@group_configs[group.to_s]
|
31
|
+
end
|
32
|
+
|
33
|
+
def redis_connection
|
34
|
+
@redis_connection ||= RedisConnection.new(redis)
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def fetch_group_configuration(group)
|
40
|
+
redis_connection.sadd "#{prefix}:groups", group.to_s
|
41
|
+
GroupConfiguration.new(redis_connection.hgetall "#{prefix}:group_config:#{group}")
|
42
|
+
end
|
43
|
+
|
44
|
+
def save_group_configuration(group, configuration)
|
45
|
+
configuration.set_defaults
|
46
|
+
redis_connection.hmset "#{prefix}:group_config:#{group}", *configuration.to_hash
|
47
|
+
redis_connection.sadd "#{prefix}:groups", group.to_s
|
48
|
+
@group_configs[group.to_s] = configuration
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module Retained
|
2
|
+
class GroupConfiguration
|
3
|
+
attr_reader :reporting_interval
|
4
|
+
|
5
|
+
ReportingIntervals = %i(day hour minute).freeze
|
6
|
+
|
7
|
+
def initialize(options = {})
|
8
|
+
@reporting_interval = nil
|
9
|
+
|
10
|
+
options.each do |key, value|
|
11
|
+
send("#{key}=", value)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def reporting_interval=(reporting_interval)
|
16
|
+
reporting_interval = reporting_interval.to_sym
|
17
|
+
|
18
|
+
if @reporting_interval && @reporting_interval != reporting_interval
|
19
|
+
fail 'Group reporting_interval is immutable once set'
|
20
|
+
elsif !ReportingIntervals.include?(reporting_interval)
|
21
|
+
fail ArgumentError, "Invalid reporting_interval: `#{reporting_interval}`. Must be one of #{ReportingIntervals}"
|
22
|
+
end
|
23
|
+
|
24
|
+
@reporting_interval = reporting_interval
|
25
|
+
end
|
26
|
+
|
27
|
+
def set_defaults
|
28
|
+
@reporting_interval ||= :day
|
29
|
+
end
|
30
|
+
|
31
|
+
# Returns the configuration as a hash of key/values
|
32
|
+
def to_hash
|
33
|
+
{
|
34
|
+
reporting_interval: reporting_interval
|
35
|
+
}
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'redis'
|
2
|
+
|
3
|
+
module Retained
|
4
|
+
class RedisConnection
|
5
|
+
attr_reader :client
|
6
|
+
|
7
|
+
def initialize(options = {})
|
8
|
+
@client = Redis.new(options)
|
9
|
+
end
|
10
|
+
|
11
|
+
def method_missing(method, *args, &block)
|
12
|
+
@client.send(method, *args, &block)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
require 'redis/bitops'
|
2
|
+
require 'active_support/core_ext/time'
|
3
|
+
require 'retained/configuration'
|
4
|
+
|
5
|
+
module Retained
|
6
|
+
class Tracker
|
7
|
+
attr_accessor :config
|
8
|
+
|
9
|
+
def initialize(config = Configuration.new)
|
10
|
+
@config = config
|
11
|
+
end
|
12
|
+
|
13
|
+
def configure
|
14
|
+
yield(config)
|
15
|
+
end
|
16
|
+
|
17
|
+
# Tracks the entity as active at the period, or now if no period
|
18
|
+
# is provided.
|
19
|
+
def retain(entity, group: group, period: Time.now)
|
20
|
+
index = entity_index(entity, group)
|
21
|
+
bitmap = config.redis_connection.sparse_bitmap(key_period(group, period))
|
22
|
+
bitmap[index] = true
|
23
|
+
end
|
24
|
+
|
25
|
+
# Total active entities in the period, or now if now period,
|
26
|
+
# is provided.
|
27
|
+
def total_active(group: group, period: Time.now)
|
28
|
+
bitmap = config.redis_connection.sparse_bitmap(key_period(group, period))
|
29
|
+
bitmap.bitcount
|
30
|
+
end
|
31
|
+
|
32
|
+
# Returns true if the entity was active in the given period,
|
33
|
+
# or now if now period is provided. If a group or an array of groups
|
34
|
+
# is provided activity will only be considered based on those groups.
|
35
|
+
def active?(entity, group: nil, period: Time.now)
|
36
|
+
group = [group] if group.is_a?(String)
|
37
|
+
group = groups if group == [] || !group
|
38
|
+
|
39
|
+
group.to_a.each do |g|
|
40
|
+
bitmap = config.redis_connection.sparse_bitmap(key_period(g, period))
|
41
|
+
index = entity_index(entity, g)
|
42
|
+
return bitmap[index] if bitmap[index]
|
43
|
+
end
|
44
|
+
false
|
45
|
+
end
|
46
|
+
|
47
|
+
# Returns an array of all groups
|
48
|
+
def groups
|
49
|
+
config.redis_connection.smembers "#{config.prefix}:groups"
|
50
|
+
end
|
51
|
+
|
52
|
+
# Returns the index (offset) of the entity within the group.
|
53
|
+
#
|
54
|
+
# Thanks to crashlytics for the monotonic_zadd approach taken here
|
55
|
+
# http://www.slideshare.net/crashlytics/crashlytics-on-redis-analytics
|
56
|
+
def entity_index(entity, group)
|
57
|
+
monotonic_zadd = <<LUA
|
58
|
+
local sequential_id = redis.call('zscore', KEYS[1], ARGV[1])
|
59
|
+
if not sequential_id then
|
60
|
+
sequential_id = redis.call('zcard', KEYS[1])
|
61
|
+
redis.call('zadd', KEYS[1], sequential_id, ARGV[1])
|
62
|
+
end
|
63
|
+
return sequential_id
|
64
|
+
LUA
|
65
|
+
|
66
|
+
key = "#{config.prefix}:entity_ids:#{group}"
|
67
|
+
config.redis_connection.eval(monotonic_zadd, [key], [entity.to_s]).to_i
|
68
|
+
end
|
69
|
+
|
70
|
+
# Returns the key for the group at the period. All periods are
|
71
|
+
# internally stored relative to UTC.
|
72
|
+
def key_period(group, period)
|
73
|
+
period = period.utc.send("beginning_of_#{config.group(group).reporting_interval}")
|
74
|
+
"#{config.prefix}:#{group}:#{period.to_i}"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
data/lib/retained.rb
ADDED
data/retained.gemspec
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
# stub: retained 0.1.0 ruby lib
|
6
|
+
|
7
|
+
Gem::Specification.new do |s|
|
8
|
+
s.name = "retained"
|
9
|
+
s.version = "0.1.0"
|
10
|
+
|
11
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
12
|
+
s.require_paths = ["lib"]
|
13
|
+
s.authors = ["Mike Saffitz"]
|
14
|
+
s.date = "2014-09-21"
|
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
|
+
s.email = "m@saffitz.com"
|
17
|
+
s.extra_rdoc_files = [
|
18
|
+
"LICENSE.txt",
|
19
|
+
"README.md"
|
20
|
+
]
|
21
|
+
s.files = [
|
22
|
+
".document",
|
23
|
+
"Gemfile",
|
24
|
+
"Gemfile.lock",
|
25
|
+
"LICENSE.txt",
|
26
|
+
"README.md",
|
27
|
+
"Rakefile",
|
28
|
+
"lib/retained.rb",
|
29
|
+
"lib/retained/configuration.rb",
|
30
|
+
"lib/retained/group_configuration.rb",
|
31
|
+
"lib/retained/redis_connection.rb",
|
32
|
+
"lib/retained/tracker.rb",
|
33
|
+
"lib/retained/version.rb",
|
34
|
+
"retained.gemspec",
|
35
|
+
"test/configuration_spec.rb",
|
36
|
+
"test/group_configuration_spec.rb",
|
37
|
+
"test/helper.rb",
|
38
|
+
"test/tracker_spec.rb"
|
39
|
+
]
|
40
|
+
s.homepage = "http://github.com/msaffitz/retained"
|
41
|
+
s.licenses = ["MIT"]
|
42
|
+
s.rubygems_version = "2.2.2"
|
43
|
+
s.summary = "Activity & Retention Tracking at Scale"
|
44
|
+
|
45
|
+
if s.respond_to? :specification_version then
|
46
|
+
s.specification_version = 4
|
47
|
+
|
48
|
+
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
|
49
|
+
s.add_runtime_dependency(%q<redis>, ["~> 3.1.0"])
|
50
|
+
s.add_runtime_dependency(%q<redis-bitops>, ["~> 0.2.1"])
|
51
|
+
s.add_runtime_dependency(%q<activesupport>, ["~> 4.1.5"])
|
52
|
+
s.add_development_dependency(%q<minitest>, [">= 0"])
|
53
|
+
s.add_development_dependency(%q<yard>, ["~> 0.7"])
|
54
|
+
s.add_development_dependency(%q<rdoc>, ["~> 3.12"])
|
55
|
+
s.add_development_dependency(%q<bundler>, ["~> 1.0"])
|
56
|
+
s.add_development_dependency(%q<jeweler>, ["~> 2.0.1"])
|
57
|
+
s.add_development_dependency(%q<simplecov>, [">= 0"])
|
58
|
+
s.add_development_dependency(%q<timecop>, ["~> 0.7.1"])
|
59
|
+
else
|
60
|
+
s.add_dependency(%q<redis>, ["~> 3.1.0"])
|
61
|
+
s.add_dependency(%q<redis-bitops>, ["~> 0.2.1"])
|
62
|
+
s.add_dependency(%q<activesupport>, ["~> 4.1.5"])
|
63
|
+
s.add_dependency(%q<minitest>, [">= 0"])
|
64
|
+
s.add_dependency(%q<yard>, ["~> 0.7"])
|
65
|
+
s.add_dependency(%q<rdoc>, ["~> 3.12"])
|
66
|
+
s.add_dependency(%q<bundler>, ["~> 1.0"])
|
67
|
+
s.add_dependency(%q<jeweler>, ["~> 2.0.1"])
|
68
|
+
s.add_dependency(%q<simplecov>, [">= 0"])
|
69
|
+
s.add_dependency(%q<timecop>, ["~> 0.7.1"])
|
70
|
+
end
|
71
|
+
else
|
72
|
+
s.add_dependency(%q<redis>, ["~> 3.1.0"])
|
73
|
+
s.add_dependency(%q<redis-bitops>, ["~> 0.2.1"])
|
74
|
+
s.add_dependency(%q<activesupport>, ["~> 4.1.5"])
|
75
|
+
s.add_dependency(%q<minitest>, [">= 0"])
|
76
|
+
s.add_dependency(%q<yard>, ["~> 0.7"])
|
77
|
+
s.add_dependency(%q<rdoc>, ["~> 3.12"])
|
78
|
+
s.add_dependency(%q<bundler>, ["~> 1.0"])
|
79
|
+
s.add_dependency(%q<jeweler>, ["~> 2.0.1"])
|
80
|
+
s.add_dependency(%q<simplecov>, [">= 0"])
|
81
|
+
s.add_dependency(%q<timecop>, ["~> 0.7.1"])
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
describe Retained::Configuration do
|
4
|
+
let(:configuration) { Retained::Configuration.new }
|
5
|
+
|
6
|
+
it 'configures groups' do
|
7
|
+
configuration.group('group_a') do |group|
|
8
|
+
group.reporting_interval = :day
|
9
|
+
end
|
10
|
+
configuration.group('group_a').reporting_interval.must_equal :day
|
11
|
+
end
|
12
|
+
|
13
|
+
it 'loads saved configuration' do
|
14
|
+
configuration.group('group_b') do |group|
|
15
|
+
group.reporting_interval = :minute
|
16
|
+
end
|
17
|
+
Retained::Configuration.new.group('group_b').reporting_interval.must_equal :minute
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
describe Retained::GroupConfiguration do
|
4
|
+
let(:group_configuration) { Retained::GroupConfiguration.new }
|
5
|
+
|
6
|
+
it 'reporting_interval is set' do
|
7
|
+
group_configuration.reporting_interval = :day
|
8
|
+
group_configuration.reporting_interval.must_equal :day
|
9
|
+
end
|
10
|
+
|
11
|
+
it 'reporting_interval cannot be changed once set' do
|
12
|
+
group_configuration.reporting_interval = :day
|
13
|
+
proc { group_configuration.reporting_interval = 'month' }.must_raise RuntimeError
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'reporting_interval must be hour, minute, or day' do
|
17
|
+
proc { group_configuration.reporting_interval = :week }.must_raise ArgumentError
|
18
|
+
proc { group_configuration.reporting_interval = 'month' }.must_raise ArgumentError
|
19
|
+
end
|
20
|
+
end
|
data/test/helper.rb
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'simplecov'
|
2
|
+
|
3
|
+
module SimpleCov::Configuration
|
4
|
+
def clean_filters
|
5
|
+
@filters = []
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
SimpleCov.configure do
|
10
|
+
clean_filters
|
11
|
+
load_profile 'test_frameworks'
|
12
|
+
end
|
13
|
+
|
14
|
+
ENV['COVERAGE'] && SimpleCov.start do
|
15
|
+
add_filter '/.rvm/'
|
16
|
+
end
|
17
|
+
require 'rubygems'
|
18
|
+
require 'bundler'
|
19
|
+
begin
|
20
|
+
Bundler.setup(:default, :development)
|
21
|
+
rescue Bundler::BundlerError => e
|
22
|
+
$stderr.puts e.message
|
23
|
+
$stderr.puts 'Run `bundle install` to install missing gems'
|
24
|
+
exit e.status_code
|
25
|
+
end
|
26
|
+
require 'minitest/spec'
|
27
|
+
|
28
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
29
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
30
|
+
require 'retained'
|
31
|
+
|
32
|
+
MiniTest.autorun
|
@@ -0,0 +1,106 @@
|
|
1
|
+
require 'helper'
|
2
|
+
require 'securerandom'
|
3
|
+
require 'timecop'
|
4
|
+
Timecop.safe_mode = true
|
5
|
+
|
6
|
+
|
7
|
+
describe Retained::Tracker do
|
8
|
+
let(:tracker) { Retained::Tracker.new }
|
9
|
+
|
10
|
+
before(:each) do
|
11
|
+
tracker.config.redis_connection.flushdb
|
12
|
+
end
|
13
|
+
|
14
|
+
it 'retain tracks using the default group in the current period' do
|
15
|
+
Timecop.freeze(Time.new(2014, 8, 29, 9, 03, 35)) do
|
16
|
+
tracker.retain('entity_a')
|
17
|
+
prefix = tracker.config.prefix
|
18
|
+
group = tracker.config.default_group
|
19
|
+
tracker.config.redis_connection.sparse_bitmap("#{prefix}:#{group}:1409328000")[0]
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'retain tracks the entity for the period specified' do
|
24
|
+
period = Time.new(2014, 8, 30, 10, 47, 35)
|
25
|
+
tracker.retain('entity_a', group: 'group_a', period: period)
|
26
|
+
|
27
|
+
prefix = tracker.config.prefix
|
28
|
+
tracker.config.redis_connection.sparse_bitmap("#{prefix}:group_a:1409356800")[0]
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'retain without a period tracks the entity for the current period' do
|
32
|
+
Timecop.freeze(Time.new(2014, 8, 29, 9, 03, 35)) do
|
33
|
+
tracker.retain('entity_a', group: 'group_a')
|
34
|
+
|
35
|
+
prefix = tracker.config.prefix
|
36
|
+
tracker.config.redis_connection.sparse_bitmap("#{prefix}:group_a:1409328000")[0]
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
it 'total_active returns the number of active entities in the default group in the period' do
|
41
|
+
period = Time.new(2014, 8, 30, 10, 47, 35)
|
42
|
+
(count = rand(100)).times do |i|
|
43
|
+
tracker.retain("entity_#{i}", period: period)
|
44
|
+
end
|
45
|
+
tracker.total_active(period: period).must_equal count
|
46
|
+
end
|
47
|
+
|
48
|
+
it 'total_active returns the number of active entities in the period' do
|
49
|
+
period = Time.new(2014, 8, 30, 10, 47, 35)
|
50
|
+
(count = rand(100)).times do |i|
|
51
|
+
tracker.retain("entity_#{i}", group: 'group_a', period: period)
|
52
|
+
end
|
53
|
+
tracker.total_active(group: 'group_a', period: period).must_equal count
|
54
|
+
end
|
55
|
+
|
56
|
+
it 'total_active without a period returns the number of active entities for the current period' do
|
57
|
+
(count = rand(100)).times do |i|
|
58
|
+
tracker.retain("entity_#{i}", group: 'group_a', period: Time.now)
|
59
|
+
end
|
60
|
+
tracker.total_active(group: 'group_a').must_equal count
|
61
|
+
end
|
62
|
+
|
63
|
+
describe 'active?' do
|
64
|
+
it 'returns true when the entity is active' do
|
65
|
+
Timecop.freeze do
|
66
|
+
tracker.retain('entity_a', group: 'group_a', period: Time.now)
|
67
|
+
tracker.active?('entity_a', group: 'group_a', period: Time.now).must_equal true
|
68
|
+
tracker.active?('entity_a', period: Time.now).must_equal true
|
69
|
+
tracker.active?('entity_a').must_equal true
|
70
|
+
tracker.active?('entity_a', group: ['group_a']).must_equal true
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
it 'returns false when the entity was not active' do
|
75
|
+
Timecop.freeze do
|
76
|
+
tracker.retain('entity_a', group: 'group_a', period: Time.now)
|
77
|
+
tracker.active?('entity_b').must_equal false
|
78
|
+
tracker.active?('entity_a', group: 'group_b').must_equal false
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
it 'entity_index returns the offset' do
|
84
|
+
group_a = SecureRandom.hex
|
85
|
+
entity_a = SecureRandom.hex
|
86
|
+
entity_b = SecureRandom.hex
|
87
|
+
|
88
|
+
tracker.entity_index(entity_a, group_a).must_equal 0
|
89
|
+
tracker.entity_index(entity_b, group_a).must_equal 1
|
90
|
+
tracker.entity_index(SecureRandom.hex, group_a).must_equal 2
|
91
|
+
tracker.entity_index(entity_a, SecureRandom.hex).must_equal 0
|
92
|
+
tracker.entity_index(entity_b, group_a).must_equal 1
|
93
|
+
end
|
94
|
+
|
95
|
+
it 'key_period returns the proper key for the given period' do
|
96
|
+
tracker.configure do |config|
|
97
|
+
config.group('hour') { |g| g.reporting_interval = :hour }
|
98
|
+
config.group('minute') { |g| g.reporting_interval = :minute }
|
99
|
+
config.group('day') { |g| g.reporting_interval = :day}
|
100
|
+
end
|
101
|
+
|
102
|
+
tracker.key_period('hour', Time.new(2014, 8, 30, 10, 35, 47, 0)).must_equal 'retained:hour:1409392800'
|
103
|
+
tracker.key_period('minute', Time.new(2014, 8, 30, 10, 35, 47, 5*3600)).must_equal 'retained:minute:1409376900'
|
104
|
+
tracker.key_period('day', Time.new(2014, 8, 30, 10, 35, 47, -2*3600)).must_equal 'retained:day:1409356800'
|
105
|
+
end
|
106
|
+
end
|
metadata
ADDED
@@ -0,0 +1,203 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: retained
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Mike Saffitz
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2014-09-21 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: redis
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 3.1.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 3.1.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: redis-bitops
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 0.2.1
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 0.2.1
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: activesupport
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 4.1.5
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 4.1.5
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: minitest
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: yard
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0.7'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0.7'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rdoc
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '3.12'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '3.12'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: bundler
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "~>"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '1.0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '1.0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: jeweler
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - "~>"
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: 2.0.1
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - "~>"
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: 2.0.1
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: simplecov
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ">="
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ">="
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0'
|
139
|
+
- !ruby/object:Gem::Dependency
|
140
|
+
name: timecop
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - "~>"
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: 0.7.1
|
146
|
+
type: :development
|
147
|
+
prerelease: false
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - "~>"
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: 0.7.1
|
153
|
+
description: "\n Easy tracking of activity and retention at scale in daily, hourly,
|
154
|
+
or minute intervals\n using sparse Redis bitmaps.\n "
|
155
|
+
email: m@saffitz.com
|
156
|
+
executables: []
|
157
|
+
extensions: []
|
158
|
+
extra_rdoc_files:
|
159
|
+
- LICENSE.txt
|
160
|
+
- README.md
|
161
|
+
files:
|
162
|
+
- ".document"
|
163
|
+
- Gemfile
|
164
|
+
- Gemfile.lock
|
165
|
+
- LICENSE.txt
|
166
|
+
- README.md
|
167
|
+
- Rakefile
|
168
|
+
- lib/retained.rb
|
169
|
+
- lib/retained/configuration.rb
|
170
|
+
- lib/retained/group_configuration.rb
|
171
|
+
- lib/retained/redis_connection.rb
|
172
|
+
- lib/retained/tracker.rb
|
173
|
+
- lib/retained/version.rb
|
174
|
+
- retained.gemspec
|
175
|
+
- test/configuration_spec.rb
|
176
|
+
- test/group_configuration_spec.rb
|
177
|
+
- test/helper.rb
|
178
|
+
- test/tracker_spec.rb
|
179
|
+
homepage: http://github.com/msaffitz/retained
|
180
|
+
licenses:
|
181
|
+
- MIT
|
182
|
+
metadata: {}
|
183
|
+
post_install_message:
|
184
|
+
rdoc_options: []
|
185
|
+
require_paths:
|
186
|
+
- lib
|
187
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
188
|
+
requirements:
|
189
|
+
- - ">="
|
190
|
+
- !ruby/object:Gem::Version
|
191
|
+
version: '0'
|
192
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
193
|
+
requirements:
|
194
|
+
- - ">="
|
195
|
+
- !ruby/object:Gem::Version
|
196
|
+
version: '0'
|
197
|
+
requirements: []
|
198
|
+
rubyforge_project:
|
199
|
+
rubygems_version: 2.2.2
|
200
|
+
signing_key:
|
201
|
+
specification_version: 4
|
202
|
+
summary: Activity & Retention Tracking at Scale
|
203
|
+
test_files: []
|