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
@@ -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
|