retained 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,5 @@
1
+ lib/**/*.rb
2
+ bin/*
3
+ -
4
+ features/**/*.feature
5
+ LICENSE.txt
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
@@ -0,0 +1,10 @@
1
+ module Retained
2
+ module Version
3
+ MAJOR = 0
4
+ MINOR = 1
5
+ PATCH = 0
6
+ BUILD = nil
7
+
8
+ STRING = [MAJOR, MINOR, PATCH, BUILD].compact.join('.')
9
+ end
10
+ end
data/lib/retained.rb ADDED
@@ -0,0 +1,13 @@
1
+ require 'retained/tracker'
2
+ require 'retained/version'
3
+ require 'forwardable'
4
+
5
+ module Retained
6
+ @tracker = Tracker.new
7
+
8
+ class << self
9
+ def method_missing(meth, *args, &block)
10
+ @tracker.send(meth, *args, &block)
11
+ end
12
+ end
13
+ end
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: []