bluecap 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
@@ -0,0 +1,54 @@
|
|
1
|
+
require 'date'
|
2
|
+
|
3
|
+
module Bluecap
|
4
|
+
class Event
|
5
|
+
|
6
|
+
attr_reader :id, :name, :timestamp
|
7
|
+
|
8
|
+
# Initialize an Event handler.
|
9
|
+
#
|
10
|
+
# data - A Hash containing event data:
|
11
|
+
# :id - The Integer identifer of the user that generated
|
12
|
+
# the event.
|
13
|
+
# :name - The String type of event.
|
14
|
+
# :timestamp - The Integer UNIX timestamp when the event was created,
|
15
|
+
# defaults to current time (optional).
|
16
|
+
#
|
17
|
+
# Examples
|
18
|
+
#
|
19
|
+
# Bluecap::Event.new(
|
20
|
+
# id: 3,
|
21
|
+
# name: 'Created Account',
|
22
|
+
# timestamp: 1341845456
|
23
|
+
# )
|
24
|
+
def initialize(data)
|
25
|
+
@id = data.fetch(:id)
|
26
|
+
@name = data.fetch(:name)
|
27
|
+
@timestamp = data.fetch(:timestamp, Time.now.to_i)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Converts the object's timestamp to a %Y%m%d String.
|
31
|
+
#
|
32
|
+
# Returns the String date.
|
33
|
+
def date
|
34
|
+
Time.at(@timestamp).strftime('%Y%m%d')
|
35
|
+
end
|
36
|
+
|
37
|
+
# Proxy for an event key.
|
38
|
+
#
|
39
|
+
# Returns the String key.
|
40
|
+
def key
|
41
|
+
Bluecap::Keys.event(@name, date)
|
42
|
+
end
|
43
|
+
|
44
|
+
# Store the user's event in a bitset of all the events matching that name
|
45
|
+
# for the date.
|
46
|
+
#
|
47
|
+
# Returns nil.
|
48
|
+
def handle
|
49
|
+
Bluecap.redis.setbit(key, @id, 1)
|
50
|
+
|
51
|
+
nil
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Bluecap
|
2
|
+
class Identify
|
3
|
+
|
4
|
+
attr_reader :name
|
5
|
+
|
6
|
+
# Initialize an Identify handler.
|
7
|
+
#
|
8
|
+
# name - A String to uniquely identify the user from the source system.
|
9
|
+
#
|
10
|
+
def initialize(name)
|
11
|
+
@name = name
|
12
|
+
end
|
13
|
+
|
14
|
+
# Returns an id to track a user in Bluecap, creating an id if one does not
|
15
|
+
# already exist.
|
16
|
+
#
|
17
|
+
# Bluecap::Identify.new('Andy').handle
|
18
|
+
# # => 1
|
19
|
+
#
|
20
|
+
# Bluecap::Identify.new('Evelyn').handle
|
21
|
+
# # => 2
|
22
|
+
#
|
23
|
+
# Returns the Integer id.
|
24
|
+
def handle
|
25
|
+
id = Bluecap.redis.hget('user.map', @name)
|
26
|
+
return id.to_i if id
|
27
|
+
|
28
|
+
id = Bluecap.redis.incr('user.ids')
|
29
|
+
if Bluecap.redis.hsetnx('user.map', @name, id)
|
30
|
+
return id
|
31
|
+
else
|
32
|
+
# Race condition, another process has set the id for this user.
|
33
|
+
# The unused id shouldn't cause inaccuracies in reporting since
|
34
|
+
# the position will be 0 in all bitsets across the system.
|
35
|
+
return redis.hget('user.map', @name).to_i
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Bluecap
|
2
|
+
class NullHandler
|
3
|
+
|
4
|
+
attr_reader :data
|
5
|
+
|
6
|
+
# Initialize a NullHandler.
|
7
|
+
def initialize(data)
|
8
|
+
@data = data
|
9
|
+
end
|
10
|
+
|
11
|
+
# Catch the handle message.
|
12
|
+
#
|
13
|
+
# Returns nothing.
|
14
|
+
def handle
|
15
|
+
Bluecap.log "NullHandler received message, contents: #{@data}"
|
16
|
+
end
|
17
|
+
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
require 'date'
|
2
|
+
require 'securerandom'
|
3
|
+
|
4
|
+
module Bluecap
|
5
|
+
class Report
|
6
|
+
|
7
|
+
attr_reader :initial_event, :engagement_event, :attributes, :year_month
|
8
|
+
|
9
|
+
# Initialize a Report handler.
|
10
|
+
#
|
11
|
+
# data - A Hash containing options to scope the report by:
|
12
|
+
# :initial_event - The String event that cohorts shared.
|
13
|
+
# :engagement_event - The String event to track engagement by.
|
14
|
+
# :attributes - The Hash attributes of users (optional).
|
15
|
+
# :start_date - The String start date of the report.
|
16
|
+
# :end_date - The String end date of the report.
|
17
|
+
#
|
18
|
+
# Examples
|
19
|
+
#
|
20
|
+
# Bluecap::Report.new(
|
21
|
+
# initial_event: 'Created Account',
|
22
|
+
# engagement_event: 'Logged In',
|
23
|
+
# attributes: {
|
24
|
+
# country: 'Australia',
|
25
|
+
# gender: 'Female'
|
26
|
+
# },
|
27
|
+
# start_date: '20120401',
|
28
|
+
# end_date: '20120430'
|
29
|
+
# )
|
30
|
+
def initialize(data)
|
31
|
+
@initial_event = data.fetch(:initial_event)
|
32
|
+
@engagement_event = data.fetch(:engagement_event)
|
33
|
+
@attributes = data.fetch(:attributes, Hash.new)
|
34
|
+
@start_date = Date.parse(data.fetch(:start_date))
|
35
|
+
@end_date = Date.parse(data.fetch(:end_date))
|
36
|
+
end
|
37
|
+
|
38
|
+
def report_id
|
39
|
+
Bluecap.redis.incr('report.ids')
|
40
|
+
end
|
41
|
+
|
42
|
+
# Generates a report to track engagement of cohorts over time.
|
43
|
+
#
|
44
|
+
# Returns the String with report data in JSON format.
|
45
|
+
def handle
|
46
|
+
report = Hash.new
|
47
|
+
(@start_date...@end_date).each do |date|
|
48
|
+
cohort = Cohort.new(
|
49
|
+
:initial_event => @initial_event,
|
50
|
+
:attributes => @attributes,
|
51
|
+
:date => date,
|
52
|
+
:report_id => report_id
|
53
|
+
)
|
54
|
+
|
55
|
+
# The start date of the engagement is measured from the day after the
|
56
|
+
# initial event.
|
57
|
+
engagement = Engagement.new(
|
58
|
+
:cohort => cohort,
|
59
|
+
:engagement_event => engagement_event,
|
60
|
+
:start_date => date + 1,
|
61
|
+
:end_date => @end_date,
|
62
|
+
:report_id => report_id
|
63
|
+
)
|
64
|
+
|
65
|
+
cohort_results = Hash.new
|
66
|
+
cohort_results[:total] = cohort.total
|
67
|
+
cohort_results[:engagement] = engagement.measure
|
68
|
+
report[date.strftime('%Y%m%d')] = cohort_results
|
69
|
+
end
|
70
|
+
|
71
|
+
MultiJson.dump(report)
|
72
|
+
end
|
73
|
+
|
74
|
+
end
|
75
|
+
end
|
data/lib/bluecap/keys.rb
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
module Bluecap
|
2
|
+
module Keys
|
3
|
+
|
4
|
+
# Returns a cleaned version of a string for use in a Redis key. Strips,
|
5
|
+
# downcases, then treats any remaining characters like word separators
|
6
|
+
# as periods.
|
7
|
+
#
|
8
|
+
# str - The String to be cleaned.
|
9
|
+
#
|
10
|
+
# Examples
|
11
|
+
#
|
12
|
+
# clean('Country')
|
13
|
+
# # => "country"
|
14
|
+
#
|
15
|
+
# clean('Logged In')
|
16
|
+
# # => "logged.in"
|
17
|
+
#
|
18
|
+
# Returns the new String.
|
19
|
+
def self.clean(str)
|
20
|
+
str.strip.downcase.gsub(/[^a-z0-9]/, '.')
|
21
|
+
end
|
22
|
+
|
23
|
+
# Returns a key used to store the events for a day.
|
24
|
+
#
|
25
|
+
# Examples
|
26
|
+
#
|
27
|
+
# Bluecap::Keys.event 'Sign Up', '20120710'
|
28
|
+
# # => "events:sign.up:20120710"
|
29
|
+
#
|
30
|
+
# Returns the String key.
|
31
|
+
def self.event(name, date)
|
32
|
+
"events:#{clean(name)}:#{date}"
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.cohort(report_id, cohort_id)
|
36
|
+
"reports:cohort:#{report_id}:#{cohort_id}"
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.engagement(report_id, cohort_id, date)
|
40
|
+
"reports:e:#{report_id}:#{cohort_id}:#{date}"
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module Bluecap
|
2
|
+
class Message
|
3
|
+
|
4
|
+
# Initialize a Message with data.
|
5
|
+
#
|
6
|
+
# data - The String JSON message to parse.
|
7
|
+
def initialize(data)
|
8
|
+
@data = MultiJson.load(data, symbolize_keys: true)
|
9
|
+
end
|
10
|
+
|
11
|
+
# The signature of the handler that should respond to the message.
|
12
|
+
#
|
13
|
+
# Examples
|
14
|
+
#
|
15
|
+
# message = Message.new('{"identify": "Evelyn"}')
|
16
|
+
# message.recipient
|
17
|
+
# # => :identify
|
18
|
+
#
|
19
|
+
# Returns the Symbol recipient of the messsage.
|
20
|
+
def recipient
|
21
|
+
@data.first[0].to_sym if @data.first
|
22
|
+
end
|
23
|
+
|
24
|
+
# The contents of the message.
|
25
|
+
#
|
26
|
+
# Examples
|
27
|
+
#
|
28
|
+
# message = Message.new('{"identify": "Evelyn"}')
|
29
|
+
# message.contents
|
30
|
+
# # => "Evelyn"
|
31
|
+
#
|
32
|
+
# Returns the contents of the message; the data type is different based on
|
33
|
+
# the contents of the message.
|
34
|
+
def contents
|
35
|
+
@data.first[1]
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
require 'socket'
|
2
|
+
|
3
|
+
module Bluecap
|
4
|
+
module Server
|
5
|
+
|
6
|
+
# Returns a Hash of handlers, creating a new empty hash if none have
|
7
|
+
# previously been set.
|
8
|
+
def self.handlers
|
9
|
+
@handlers ||= {
|
10
|
+
attributes: Bluecap::Attributes,
|
11
|
+
event: Bluecap::Event,
|
12
|
+
identify: Bluecap::Identify,
|
13
|
+
report: Bluecap::Report
|
14
|
+
}
|
15
|
+
end
|
16
|
+
|
17
|
+
# Assign handlers to respond to new data.
|
18
|
+
#
|
19
|
+
# handlers - A Hash of lookup keys to handler classes.
|
20
|
+
#
|
21
|
+
# Examples
|
22
|
+
#
|
23
|
+
# handlers = {
|
24
|
+
# event: Bluecap::Event,
|
25
|
+
# identify: Bluecap::Identify
|
26
|
+
# }
|
27
|
+
#
|
28
|
+
# Returns nothing.
|
29
|
+
def self.handlers=(handlers)
|
30
|
+
@handlers = handlers
|
31
|
+
end
|
32
|
+
|
33
|
+
def process(message)
|
34
|
+
klass = Bluecap::Server.handlers.fetch(message.recipient, Bluecap::NullHandler)
|
35
|
+
port, ip_address = Socket.unpack_sockaddr_in(get_peername)
|
36
|
+
Bluecap.log "Message received for #{message.recipient} handler from #{ip_address}:#{port}"
|
37
|
+
handler = klass.new(message.contents)
|
38
|
+
handler.handle
|
39
|
+
end
|
40
|
+
|
41
|
+
# Process a message received from a client, sending a response if
|
42
|
+
# necessary.
|
43
|
+
#
|
44
|
+
# data - The String containing JSON received from the client.
|
45
|
+
#
|
46
|
+
# Examples
|
47
|
+
#
|
48
|
+
# receive_data('{"identify": "Andy"}')
|
49
|
+
#
|
50
|
+
# Returns nothing.
|
51
|
+
def receive_data(data)
|
52
|
+
begin
|
53
|
+
message = Bluecap::Message.new(data)
|
54
|
+
response = process(message)
|
55
|
+
send_data(response) if response
|
56
|
+
rescue Exception => e
|
57
|
+
Bluecap.log e
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Starts a TCP server running on the EventMachine loop.
|
62
|
+
#
|
63
|
+
# Returns nothing.
|
64
|
+
def self.run
|
65
|
+
EventMachine::run do
|
66
|
+
EventMachine::start_server(Bluecap.host, Bluecap.port, Bluecap::Server)
|
67
|
+
Bluecap.log "Server started on #{Bluecap.host}:#{Bluecap.port}"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
describe Bluecap::Attributes do
|
4
|
+
|
5
|
+
before do
|
6
|
+
Bluecap.redis.flushall
|
7
|
+
end
|
8
|
+
|
9
|
+
subject do
|
10
|
+
Bluecap::Attributes.new :id => 3,
|
11
|
+
:attributes => {:country => 'Australia', :gender => 'Female'}
|
12
|
+
end
|
13
|
+
|
14
|
+
it 'should create attributes key' do
|
15
|
+
subject.key('gender', 'female').should == 'attributes:gender:female'
|
16
|
+
end
|
17
|
+
|
18
|
+
it 'should create attributes key using cleaned name and date' do
|
19
|
+
subject.key(' COUNTRY', 'Australia').should == 'attributes:country:australia'
|
20
|
+
end
|
21
|
+
|
22
|
+
it 'should create keys for multiple attributes' do
|
23
|
+
subject.keys.should =~ ['attributes:country:australia',
|
24
|
+
'attributes:gender:female']
|
25
|
+
end
|
26
|
+
|
27
|
+
it 'should track attributes for user by setting corresponding bits to 1' do
|
28
|
+
data = {
|
29
|
+
attributes: {
|
30
|
+
gender: 'female',
|
31
|
+
country: 'australia',
|
32
|
+
},
|
33
|
+
id: 5
|
34
|
+
}
|
35
|
+
|
36
|
+
subject.handle
|
37
|
+
|
38
|
+
# Check that bits have changed.
|
39
|
+
Bluecap.redis.getbit('attributes:gender:female', subject.id).should == 1
|
40
|
+
Bluecap.redis.getbit('attributes:country:australia', subject.id).should == 1
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
data/spec/cohort_spec.rb
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
require 'helper'
|
2
|
+
require 'date'
|
3
|
+
|
4
|
+
describe Bluecap::Cohort do
|
5
|
+
|
6
|
+
before do
|
7
|
+
Bluecap.redis.flushall
|
8
|
+
|
9
|
+
@date = Date.parse('20120701')
|
10
|
+
@initial_event = 'Sign Up'
|
11
|
+
@attributes = {:country => 'Australia', :gender => 'Female'}
|
12
|
+
@users = {
|
13
|
+
:evelyn => Bluecap::Identify.new('Evelyn').handle,
|
14
|
+
:charlotte => Bluecap::Identify.new('Charlotte').handle
|
15
|
+
}
|
16
|
+
@users.values.each do |id|
|
17
|
+
event = Bluecap::Event.new :id => id,
|
18
|
+
:name => @initial_event,
|
19
|
+
:timestamp => @date.to_time.to_i
|
20
|
+
event.handle
|
21
|
+
|
22
|
+
attribute = Bluecap::Attributes.new :id => id,
|
23
|
+
:attributes => @attributes
|
24
|
+
attribute.handle
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
it 'should find cohort total for date' do
|
29
|
+
cohort = Bluecap::Cohort.new :initial_event => @initial_event,
|
30
|
+
:date => @date,
|
31
|
+
:report_id => 1
|
32
|
+
|
33
|
+
cohort.total.should == 2
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'should not include in cohort if initial event did not occur on date' do
|
37
|
+
sarah = Bluecap::Identify.new('Sarah').handle
|
38
|
+
event = Bluecap::Event.new :id => sarah,
|
39
|
+
:name => @initial_event,
|
40
|
+
:timestamp => (@date + 1).to_time.to_i
|
41
|
+
event.handle
|
42
|
+
|
43
|
+
cohort = Bluecap::Cohort.new :initial_event => @initial_event,
|
44
|
+
:date => @date,
|
45
|
+
:report_id => 1
|
46
|
+
cohort.total.should == 2
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'should allow cohorts to be constructed with attributes of users' do
|
50
|
+
cohort = Bluecap::Cohort.new :initial_event => @initial_event,
|
51
|
+
:date => @date,
|
52
|
+
:attributes => @attributes,
|
53
|
+
:report_id => 1
|
54
|
+
|
55
|
+
cohort.total.should == 2
|
56
|
+
end
|
57
|
+
|
58
|
+
it 'shold not include in cohort if attributes are not matched' do
|
59
|
+
sarah = Bluecap::Identify.new('Sarah').handle
|
60
|
+
event = Bluecap::Event.new :id => sarah,
|
61
|
+
:name => @initial_event,
|
62
|
+
:timestamp => @date.to_time.to_i
|
63
|
+
event.handle
|
64
|
+
attributes = @attributes.clone
|
65
|
+
attributes[:country] = 'New Zealand'
|
66
|
+
attribute = Bluecap::Attributes.new :id => sarah,
|
67
|
+
:attributes => attributes
|
68
|
+
attribute.handle
|
69
|
+
|
70
|
+
cohort = Bluecap::Cohort.new :initial_event => @initial_event,
|
71
|
+
:date => @date,
|
72
|
+
:attributes => @attributes,
|
73
|
+
:report_id => 1
|
74
|
+
cohort.total.should == 2
|
75
|
+
end
|
76
|
+
|
77
|
+
end
|