bluecap 0.0.1
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.
- data/LICENSE +20 -0
- data/README.md +115 -0
- data/bin/bluecap +35 -0
- data/lib/bluecap.rb +78 -0
- data/lib/bluecap/cohort.rb +92 -0
- data/lib/bluecap/engagement.rb +79 -0
- data/lib/bluecap/handlers/attributes.rb +68 -0
- data/lib/bluecap/handlers/event.rb +54 -0
- data/lib/bluecap/handlers/identify.rb +40 -0
- data/lib/bluecap/handlers/null_handler.rb +19 -0
- data/lib/bluecap/handlers/report.rb +75 -0
- data/lib/bluecap/keys.rb +44 -0
- data/lib/bluecap/message.rb +39 -0
- data/lib/bluecap/server.rb +72 -0
- data/spec/attributes_spec.rb +43 -0
- data/spec/cohort_spec.rb +77 -0
- data/spec/engagement_spec.rb +54 -0
- data/spec/event_spec.rb +26 -0
- data/spec/helper.rb +19 -0
- data/spec/identify_spec.rb +23 -0
- data/spec/key_spec.rb +19 -0
- data/spec/redis-test.conf +544 -0
- data/spec/report_spec.rb +28 -0
- metadata +104 -0
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) Christopher Wright
|
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,115 @@
|
|
1
|
+
# Bluecap
|
2
|
+
|
3
|
+
Bluecap is a Redis-backed system for measuring user engagement over time. It
|
4
|
+
tracks events passed in from external systems, such as when a user creates an
|
5
|
+
account, logs in or performs some other key action. Bluecap can also track
|
6
|
+
properties of users like their gender or location.
|
7
|
+
|
8
|
+
Systems can then query Bluecap for data on how a group of users engages with
|
9
|
+
a product over time, e.g.: for users that created an account a month ago, what
|
10
|
+
percentage of these users have logged into their account since then? How about
|
11
|
+
the same report, but only for users that are living in Australia?
|
12
|
+
|
13
|
+
## Installation
|
14
|
+
|
15
|
+
Bluecap requires Ruby 1.9.2 or greater, and Redis 2.5.11 or greater for access
|
16
|
+
to bitcount and bitop commands. Install the gem by running:
|
17
|
+
|
18
|
+
gem install bluecap
|
19
|
+
|
20
|
+
## Usage
|
21
|
+
|
22
|
+
Run the server:
|
23
|
+
|
24
|
+
bluecap
|
25
|
+
|
26
|
+
By default this will run Bluecap on `0.0.0.0:6088` with Redis on
|
27
|
+
`localhost:6379`. Run `bluecap -h` for options.
|
28
|
+
|
29
|
+
The server responds to JSON messages received over TCP, the root level key in
|
30
|
+
the JSON indicates the type of message being sent. Valid message types are:
|
31
|
+
`identify`, `event`, `attributes` and `report`.
|
32
|
+
|
33
|
+
### Identify a user
|
34
|
+
|
35
|
+
Send Bluecap a string that uniquely identifies the user in your own system:
|
36
|
+
|
37
|
+
{"identify": "Charlotte"}
|
38
|
+
# => 1
|
39
|
+
|
40
|
+
{"identify": {"brian@example.com"}
|
41
|
+
# => 2
|
42
|
+
|
43
|
+
The server responds with an integer id. When tracking events for the user, the
|
44
|
+
id is passed along with event data.
|
45
|
+
|
46
|
+
### Tracking events
|
47
|
+
|
48
|
+
Send the id, name of the event to track, and optionally a UNIX timestamp:
|
49
|
+
|
50
|
+
{"event": {"id": 1, "name": "Created Account", "timestamp": "1341845456"}}
|
51
|
+
|
52
|
+
### Setting user attributes
|
53
|
+
|
54
|
+
Send the id and a hash of attributes to set:
|
55
|
+
|
56
|
+
{
|
57
|
+
"attributes": {
|
58
|
+
"id": 1,
|
59
|
+
"attributes": {
|
60
|
+
"gender": "Female",
|
61
|
+
"country": "Australia"
|
62
|
+
}
|
63
|
+
}
|
64
|
+
|
65
|
+
### Generating a report
|
66
|
+
|
67
|
+
The reports generate retention for users over a date range. The report requires
|
68
|
+
an initial event name to identify cohorts, an event name that measures
|
69
|
+
engagement, a date range and optional attributes to limit the users contained
|
70
|
+
in the report:
|
71
|
+
|
72
|
+
{
|
73
|
+
"report": {
|
74
|
+
"initial_event": "Created Account",
|
75
|
+
"engagement_event": "Logged In",
|
76
|
+
"attributes": {
|
77
|
+
"country": "Australia",
|
78
|
+
"gender": "Female"
|
79
|
+
},
|
80
|
+
"start_date": "20120701",
|
81
|
+
"end_date": "20120731",
|
82
|
+
}
|
83
|
+
}
|
84
|
+
|
85
|
+
A report is returned in JSON format showing the engagement over time for each
|
86
|
+
cohort, e.g.:
|
87
|
+
|
88
|
+
{
|
89
|
+
"20120701": {
|
90
|
+
"total": 3751,
|
91
|
+
"engagement": {
|
92
|
+
"20120702": 53.97,
|
93
|
+
"20120703": 43.22,
|
94
|
+
...
|
95
|
+
},
|
96
|
+
},
|
97
|
+
"20120702": {
|
98
|
+
"total": 4099,
|
99
|
+
"engagement": {
|
100
|
+
"20120703": 55.81,
|
101
|
+
"20120704": 46.73,
|
102
|
+
...
|
103
|
+
},
|
104
|
+
}
|
105
|
+
}
|
106
|
+
|
107
|
+
## Development
|
108
|
+
|
109
|
+
Clone the repository and run the tests:
|
110
|
+
|
111
|
+
git clone git://github.com/christopherwright/bluecap.git
|
112
|
+
cd bluecap
|
113
|
+
rspec
|
114
|
+
|
115
|
+
The tests will attempt to create a Redis instance by running `redis-server`.
|
data/bin/bluecap
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
$:.unshift File.dirname(__FILE__) + '/../lib'
|
4
|
+
require 'rubygems'
|
5
|
+
require 'bundler/setup'
|
6
|
+
require 'optparse'
|
7
|
+
require 'bluecap'
|
8
|
+
|
9
|
+
parser = OptionParser.new do |opts|
|
10
|
+
opts.banner = 'Usage: bluecap [options]'
|
11
|
+
|
12
|
+
opts.separator ''
|
13
|
+
opts.separator 'Options:'
|
14
|
+
|
15
|
+
opts.on('-r', '--redis [HOST:PORT]', 'Redis connection string') do |redis|
|
16
|
+
Bluecap.redis = redis
|
17
|
+
end
|
18
|
+
|
19
|
+
opts.on('-b', '--bind [HOST]', 'Hostname to bind to') do |host|
|
20
|
+
Bluecap.host = host
|
21
|
+
end
|
22
|
+
|
23
|
+
opts.on('-p', '--port [PORT]', 'Port to listen on') do |port|
|
24
|
+
Bluecap.port = port
|
25
|
+
end
|
26
|
+
|
27
|
+
opts.on('-h', '--help', 'Show this message') do
|
28
|
+
puts opts
|
29
|
+
exit
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
parser.parse!
|
34
|
+
Bluecap.log 'Starting server'
|
35
|
+
server = Bluecap::Server.run
|
data/lib/bluecap.rb
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
require 'eventmachine'
|
2
|
+
require 'redis'
|
3
|
+
require 'multi_json'
|
4
|
+
|
5
|
+
require 'bluecap/keys'
|
6
|
+
require 'bluecap/message'
|
7
|
+
require 'bluecap/cohort'
|
8
|
+
require 'bluecap/engagement'
|
9
|
+
require 'bluecap/server'
|
10
|
+
require 'bluecap/handlers/attributes'
|
11
|
+
require 'bluecap/handlers/event'
|
12
|
+
require 'bluecap/handlers/identify'
|
13
|
+
require 'bluecap/handlers/null_handler'
|
14
|
+
require 'bluecap/handlers/report'
|
15
|
+
|
16
|
+
module Bluecap
|
17
|
+
|
18
|
+
extend self
|
19
|
+
|
20
|
+
# Connect to Redis and store the resulting client.
|
21
|
+
#
|
22
|
+
# server - A String of conncetion details in host:port format.
|
23
|
+
#
|
24
|
+
# Examples
|
25
|
+
#
|
26
|
+
# redis = 'localhost:6379'
|
27
|
+
#
|
28
|
+
# Returns nothing.
|
29
|
+
def redis=(server)
|
30
|
+
host, port, database = server.split(':')
|
31
|
+
@redis = Redis.new(host: host, port: port, database: database)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Returns the Redis client, creating a new client if one does not already
|
35
|
+
# exist.
|
36
|
+
def redis
|
37
|
+
return @redis if @redis
|
38
|
+
self.redis = 'localhost:6379'
|
39
|
+
self.redis
|
40
|
+
end
|
41
|
+
|
42
|
+
# Set the host to bind to.
|
43
|
+
#
|
44
|
+
# Returns nothing.
|
45
|
+
def host=(host)
|
46
|
+
@host = host
|
47
|
+
end
|
48
|
+
|
49
|
+
# Returns the String host to bind to.
|
50
|
+
def host
|
51
|
+
return @host if @host
|
52
|
+
self.host = '0.0.0.0'
|
53
|
+
self.host
|
54
|
+
end
|
55
|
+
|
56
|
+
# Set the port to bind to.
|
57
|
+
#
|
58
|
+
# Returns nothing.
|
59
|
+
def port=(port)
|
60
|
+
@port = port
|
61
|
+
end
|
62
|
+
|
63
|
+
# Returns the Integer port to bind to.
|
64
|
+
def port
|
65
|
+
return @port if @port
|
66
|
+
self.port = 6088
|
67
|
+
self.port
|
68
|
+
end
|
69
|
+
|
70
|
+
# Log a message to STDOUT.
|
71
|
+
#
|
72
|
+
# Returns nothing.
|
73
|
+
def log(message)
|
74
|
+
time = Time.now.strftime('%Y-%m-%d %H:%M:%S')
|
75
|
+
puts "#{time} - #{message}"
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
module Bluecap
|
2
|
+
class Cohort
|
3
|
+
|
4
|
+
attr_reader :initial_event, :attributes, :date, :report_id
|
5
|
+
|
6
|
+
# Initialize a Cohort.
|
7
|
+
#
|
8
|
+
# options - A Hash containing options to define the cohort:
|
9
|
+
# :initial_event - The String event that members of the cohort
|
10
|
+
# shared.
|
11
|
+
# :attributes - The Hash attributes of cohort members.
|
12
|
+
# :date - The Date the initial event occurred on.
|
13
|
+
# :report_id - The Integer identifier of the report this
|
14
|
+
# cohort is being used with.
|
15
|
+
#
|
16
|
+
# Examples
|
17
|
+
#
|
18
|
+
# Bluecap::Cohort.new :initial_event => 'Created Account',
|
19
|
+
# :attributes => {:country => 'Australia', :gender => 'Female'},
|
20
|
+
# :date => Date.parse('20120701'),
|
21
|
+
# :report_id => 1
|
22
|
+
def initialize(options)
|
23
|
+
@initial_event = options.fetch(:initial_event)
|
24
|
+
@attributes = options.fetch(:attributes, Hash.new)
|
25
|
+
@date = options.fetch(:date)
|
26
|
+
@report_id = options.fetch(:report_id)
|
27
|
+
end
|
28
|
+
|
29
|
+
# Convert the date of the cohort to a string.
|
30
|
+
#
|
31
|
+
# Returns the String date.
|
32
|
+
def date_str
|
33
|
+
@date.strftime("%Y%m%d")
|
34
|
+
end
|
35
|
+
|
36
|
+
# An identifier for the cohort. The date of the initial event is currently
|
37
|
+
# unique for each cohort in a report, but that might change in the future if
|
38
|
+
# cohorts are not limited to the initial event occurring on a single day.
|
39
|
+
#
|
40
|
+
# Returns the String id.
|
41
|
+
def id
|
42
|
+
date_str
|
43
|
+
end
|
44
|
+
|
45
|
+
# A Redis key containing a bitmask of all the properties for this cohort.
|
46
|
+
# The method is memoized since the bitmask is cached in Redis for an hour.
|
47
|
+
#
|
48
|
+
# Returns the String key name in Redis.
|
49
|
+
def key
|
50
|
+
return @key if @key
|
51
|
+
|
52
|
+
key_name = Bluecap::Keys.cohort(@report_id, id)
|
53
|
+
Bluecap.redis.multi do
|
54
|
+
Bluecap.redis.bitop('and', key_name, keys)
|
55
|
+
Bluecap.redis.expire(key_name, 3600)
|
56
|
+
end
|
57
|
+
@key = key_name
|
58
|
+
end
|
59
|
+
|
60
|
+
# Generate an array of Redis keys where the attributes of users in the
|
61
|
+
# cohort can be found.
|
62
|
+
#
|
63
|
+
# Returns the Array of String keys.
|
64
|
+
def keys_for_attributes
|
65
|
+
attributes = Bluecap::Attributes.new :id => 0, :attributes => @attributes
|
66
|
+
attributes.keys
|
67
|
+
end
|
68
|
+
|
69
|
+
# Generate an array of Redis keys for use in creating a bitmask of the
|
70
|
+
# cohort. This consists of the bitset of the users with the initial event
|
71
|
+
# along with all the attributes of the cohort.
|
72
|
+
#
|
73
|
+
# Returns the Array of String keys.
|
74
|
+
def keys
|
75
|
+
keys_for_initial_event = Array.new
|
76
|
+
keys_for_initial_event.push(Bluecap::Keys.event(@initial_event, date_str))
|
77
|
+
keys_for_initial_event += keys_for_attributes
|
78
|
+
end
|
79
|
+
|
80
|
+
# Calculate the total number of users in this cohort by doing a bitcount
|
81
|
+
# on the cohort bitmask. The method is memoized as the calculation is
|
82
|
+
# expensive.
|
83
|
+
#
|
84
|
+
# Returns the Integer total.
|
85
|
+
def total
|
86
|
+
return @total if @total
|
87
|
+
|
88
|
+
@total = Bluecap.redis.bitcount(key) || 0
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
module Bluecap
|
2
|
+
class Engagement
|
3
|
+
|
4
|
+
# Initialize an Engagement class to measure Cohort retention.
|
5
|
+
#
|
6
|
+
# options - A hash containing options that define the engagement.
|
7
|
+
# :cohort - The Cohort to measure engagement for.
|
8
|
+
# :engagement_event - The String event that defines engagement.
|
9
|
+
# :start_date - The Date starting period to measure
|
10
|
+
# engagement for.
|
11
|
+
# :end_date - The Date ending period to measure
|
12
|
+
# engagement for.
|
13
|
+
# :report_id - The Integer identifier of the report this
|
14
|
+
# cohort is being used with.
|
15
|
+
#
|
16
|
+
# Examples
|
17
|
+
#
|
18
|
+
# engagement = Bluecap::Engagement.new :cohort => cohort,
|
19
|
+
# :engagement_event => 'Logged In',
|
20
|
+
# :start_date => Date.parse('20120702'),
|
21
|
+
# :end_date => Date.parse('20120707'),
|
22
|
+
# :report_id => 1
|
23
|
+
def initialize(options)
|
24
|
+
@cohort = options.fetch(:cohort)
|
25
|
+
@engagement_event = options.fetch(:engagement_event)
|
26
|
+
@start_date = options.fetch(:start_date)
|
27
|
+
@end_date = options.fetch(:end_date)
|
28
|
+
@report_id = options.fetch(:report_id)
|
29
|
+
end
|
30
|
+
|
31
|
+
# A key name in Redis where the bitcount operation for engagement on a
|
32
|
+
# date should be stored.
|
33
|
+
#
|
34
|
+
# Returns the String key name.
|
35
|
+
def key(date)
|
36
|
+
Bluecap::Keys.engagement(@report_id, @cohort.id, date.strftime('%Y%m%d'))
|
37
|
+
end
|
38
|
+
|
39
|
+
# The keys that comprise the bitmask for the engagement on a date.
|
40
|
+
#
|
41
|
+
# Returns the Array of String keys.
|
42
|
+
def keys(date)
|
43
|
+
[@cohort.key, Bluecap::Keys.event(@engagement_event, date.strftime('%Y%m%d'))]
|
44
|
+
end
|
45
|
+
|
46
|
+
# Measure the engagement of a cohort over a given period.
|
47
|
+
#
|
48
|
+
# Examples
|
49
|
+
#
|
50
|
+
# measure
|
51
|
+
# # => {"20120702" => 100.0, "20120703" => 50.0, ...}
|
52
|
+
#
|
53
|
+
# Returns a Hash with engagement percentages for each day in the period.
|
54
|
+
def measure
|
55
|
+
results = Hash.new
|
56
|
+
(@start_date..@end_date).each do |date|
|
57
|
+
key_name = key(date)
|
58
|
+
Bluecap.redis.multi do
|
59
|
+
Bluecap.redis.bitop('and', key_name, keys(date))
|
60
|
+
Bluecap.redis.expire(key_name, 3600)
|
61
|
+
end
|
62
|
+
sum_for_day = Bluecap.redis.bitcount(key_name) || 0
|
63
|
+
|
64
|
+
if not @cohort.total.zero?
|
65
|
+
engagement_for_day = (sum_for_day.to_f / @cohort.total)
|
66
|
+
else
|
67
|
+
engagement_for_day = 0.to_f
|
68
|
+
end
|
69
|
+
engagement_for_day *= 100.0
|
70
|
+
engagement_for_day.round(2)
|
71
|
+
|
72
|
+
results[date.strftime('%Y%m%d')] = engagement_for_day
|
73
|
+
end
|
74
|
+
|
75
|
+
results
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
module Bluecap
|
2
|
+
class Attributes
|
3
|
+
|
4
|
+
attr_reader :id, :attributes
|
5
|
+
|
6
|
+
# Initialize an Attributes handler.
|
7
|
+
#
|
8
|
+
# data - A Hash containing attributes to set for a user:
|
9
|
+
# :attributes - The hash key/value pairs to be set.
|
10
|
+
# :id - The id of the user to set the attributes for.
|
11
|
+
#
|
12
|
+
# Examples
|
13
|
+
#
|
14
|
+
# Bluecap::Attributes.new(
|
15
|
+
# id: 3,
|
16
|
+
# attributes: {
|
17
|
+
# gender: 'Female',
|
18
|
+
# country: 'Australia'
|
19
|
+
# }
|
20
|
+
# )
|
21
|
+
def initialize(data)
|
22
|
+
@id = data.fetch(:id)
|
23
|
+
@attributes = data.fetch(:attributes)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Returns keys for each of the attributes.
|
27
|
+
#
|
28
|
+
# Examples
|
29
|
+
#
|
30
|
+
# attributes = Bluecap::Attributes.new(
|
31
|
+
# id:3,
|
32
|
+
# attributes: {
|
33
|
+
# gender: 'Female',
|
34
|
+
# country: 'Australia'
|
35
|
+
# }
|
36
|
+
# attributes.keys
|
37
|
+
# # => ["attributes:gender:female", "attributes:country:australia"]
|
38
|
+
#
|
39
|
+
# Returns the Array of keys.
|
40
|
+
def keys
|
41
|
+
@attributes.map { |k, v| key(k.to_s, v) }
|
42
|
+
end
|
43
|
+
|
44
|
+
# Returns a cleaned key for an attribute and value.
|
45
|
+
#
|
46
|
+
# Examples
|
47
|
+
#
|
48
|
+
# key('gender', 'female')
|
49
|
+
# # => "attributes:gender:female"
|
50
|
+
def key(attribute, value)
|
51
|
+
"attributes:#{Bluecap::Keys.clean(attribute)}:#{Bluecap::Keys.clean(value)}"
|
52
|
+
end
|
53
|
+
|
54
|
+
# Store attributes for a user. Each attribute/value has its own bitset
|
55
|
+
# so this is best used with data that has a limited number of values
|
56
|
+
# (e.g.: gender, country).
|
57
|
+
#
|
58
|
+
# Returns nil.
|
59
|
+
def handle
|
60
|
+
Bluecap.redis.multi do
|
61
|
+
keys.each { |k| Bluecap.redis.setbit(k, @id, 1) }
|
62
|
+
end
|
63
|
+
|
64
|
+
nil
|
65
|
+
end
|
66
|
+
|
67
|
+
end
|
68
|
+
end
|