hackle-ruby-sdk 0.0.1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -3
  3. data/hackle-ruby-sdk.gemspec +1 -1
  4. data/lib/hackle-ruby-sdk.rb +2 -21
  5. data/lib/hackle.rb +76 -0
  6. data/lib/hackle/client.rb +127 -0
  7. data/lib/{hackle-ruby-sdk → hackle}/config.rb +1 -1
  8. data/lib/hackle/decision/bucketer.rb +44 -0
  9. data/lib/hackle/decision/decider.rb +69 -0
  10. data/lib/{hackle-ruby-sdk → hackle}/events/event_dispatcher.rb +31 -24
  11. data/lib/{hackle-ruby-sdk → hackle}/events/event_processor.rb +22 -11
  12. data/lib/hackle/events/user_event.rb +61 -0
  13. data/lib/{hackle-ruby-sdk → hackle}/http/http.rb +7 -9
  14. data/lib/hackle/models/bucket.rb +26 -0
  15. data/lib/hackle/models/event.rb +26 -0
  16. data/lib/hackle/models/event_type.rb +22 -0
  17. data/lib/hackle/models/experiment.rb +69 -0
  18. data/lib/hackle/models/slot.rb +22 -0
  19. data/lib/hackle/models/user.rb +24 -0
  20. data/lib/hackle/models/variation.rb +21 -0
  21. data/lib/{hackle-ruby-sdk → hackle}/version.rb +2 -2
  22. data/lib/hackle/workspaces/http_workspace_fetcher.rb +24 -0
  23. data/lib/{hackle-ruby-sdk → hackle}/workspaces/polling_workspace_fetcher.rb +4 -1
  24. data/lib/hackle/workspaces/workspace.rb +100 -0
  25. metadata +22 -19
  26. data/lib/hackle-ruby-sdk/client.rb +0 -108
  27. data/lib/hackle-ruby-sdk/decision/bucketer.rb +0 -26
  28. data/lib/hackle-ruby-sdk/decision/decider.rb +0 -54
  29. data/lib/hackle-ruby-sdk/events/event.rb +0 -33
  30. data/lib/hackle-ruby-sdk/models/bucket.rb +0 -15
  31. data/lib/hackle-ruby-sdk/models/event_type.rb +0 -10
  32. data/lib/hackle-ruby-sdk/models/experiment.rb +0 -39
  33. data/lib/hackle-ruby-sdk/models/slot.rb +0 -15
  34. data/lib/hackle-ruby-sdk/models/variation.rb +0 -11
  35. data/lib/hackle-ruby-sdk/workspaces/http_workspace_fetcher.rb +0 -24
  36. data/lib/hackle-ruby-sdk/workspaces/workspace.rb +0 -78
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 81db70f8f36a1dca6c3f03bfef469313434fd8f17e053ab32baee9c7f029f8ac
4
- data.tar.gz: beb13a5d67bbb19d4e7a11556021cc8ca911d8e20902d191fc98973aa0683581
3
+ metadata.gz: 2eb9518d11c4980c9a6abf036b8400fad5de381db6c60d2fc98b68e37cda8851
4
+ data.tar.gz: faf6536122c01e9a55f95eef4e6497313a7e09e2ff16ea1123ca6c7fdc56470a
5
5
  SHA512:
6
- metadata.gz: a8c56e2a506eb133d44bbcce75cfe523e29105813147a458b0bef4728e90fa833f630b9a4432c9b7642ba1c201397755dd501eec4db5f69c55d311dbbdeaeee8
7
- data.tar.gz: 1685a42a07a605653b86b3b7af369abdde427dba4308fcf763c9a4a3fad07ddcd87b1ba6a1561d2cb84ff14b878f86eea34e7b4aea1206a358e168961cb70fca
6
+ metadata.gz: cbf193163d0b5926f052e76b7b01e0ca472297ef5da32b2317e90a1f35450cefa6496a72bcd0e9d6c7faf83947b6a9379e07be7111f5fa0a5030b891013c6c1c
7
+ data.tar.gz: e9d8ed3a4439684fb0b3308edb0a2e48c36ba041fa8f081e33ddad237ee8a5b546f0c98a1ec05b5c4123f76fe9289bc2ea3e84d9b02b068a3833472eec77691e
data/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # Hackle::Ruby::Sdk
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/hackle/ruby/sdk`. To experiment with that code, run `bin/console` for an interactive prompt.
4
-
5
- TODO: Delete this and the text above, and describe your gem
3
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/hackle`.
6
4
 
7
5
  ## Installation
8
6
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  lib = File.expand_path('lib', __dir__)
4
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
- require 'hackle-ruby-sdk/version'
5
+ require 'hackle/version'
6
6
 
7
7
  Gem::Specification.new do |spec|
8
8
  spec.name = 'hackle-ruby-sdk'
@@ -1,22 +1,3 @@
1
- require 'hackle-ruby-sdk/decision/bucketer'
2
- require 'hackle-ruby-sdk/decision/decider'
1
+ # frozen_string_literal: true
3
2
 
4
- require 'hackle-ruby-sdk/events/event'
5
- require 'hackle-ruby-sdk/events/event_dispatcher'
6
- require 'hackle-ruby-sdk/events/event_processor'
7
-
8
- require 'hackle-ruby-sdk/http/http'
9
-
10
- require 'hackle-ruby-sdk/models/bucket'
11
- require 'hackle-ruby-sdk/models/event_type'
12
- require 'hackle-ruby-sdk/models/experiment'
13
- require 'hackle-ruby-sdk/models/slot'
14
- require 'hackle-ruby-sdk/models/variation'
15
-
16
- require 'hackle-ruby-sdk/workspaces/http_workspace_fetcher'
17
- require 'hackle-ruby-sdk/workspaces/polling_workspace_fetcher'
18
- require 'hackle-ruby-sdk/workspaces/workspace'
19
-
20
- require 'hackle-ruby-sdk/client'
21
- require 'hackle-ruby-sdk/config'
22
- require 'hackle-ruby-sdk/version'
3
+ require 'hackle'
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hackle/client'
4
+ require 'hackle/config'
5
+ require 'hackle/version'
6
+
7
+ module Hackle
8
+
9
+ #
10
+ # Instantiates a Hackle client.
11
+ #
12
+ # @see Client#initialize
13
+ #
14
+ # @param sdk_key [String] The SDK key of your Hackle environment
15
+ # @param options Optional parameters of configuration options
16
+ #
17
+ # @return [Client] The Hackle client instance.
18
+ #
19
+ def self.client(sdk_key:, **options)
20
+ config = Config.new(options)
21
+ sdk_info = SdkInfo.new(key: sdk_key)
22
+
23
+ http_workspace_fetcher = HttpWorkspaceFetcher.new(config: config, sdk_info: sdk_info)
24
+ polling_workspace_fetcher = PollingWorkspaceFetcher.new(config: config, http_fetcher: http_workspace_fetcher)
25
+
26
+ event_dispatcher = EventDispatcher.new(config: config, sdk_info: sdk_info)
27
+ event_processor = EventProcessor.new(config: config, event_dispatcher: event_dispatcher)
28
+
29
+ polling_workspace_fetcher.start!
30
+ event_processor.start!
31
+
32
+ Client.new(
33
+ config: config,
34
+ workspace_fetcher: polling_workspace_fetcher,
35
+ event_processor: event_processor,
36
+ decider: Decider.new
37
+ )
38
+ end
39
+
40
+ #
41
+ # Instantiate a user to be used for the hackle sdk.
42
+ #
43
+ # The only required parameter is `id`, which must uniquely identify each user.
44
+ #
45
+ # @example
46
+ # Hackle.user(id: 'ae2182e0')
47
+ # Hackle.user(id: 'ae2182e0', app_version: '1.0.1', paying_customer: false)
48
+ #
49
+ # @param id [String] The identifier of the user. (e.g. device_id, account_id etc.)
50
+ # @param properties Additional properties of the user. (e.g. app_version, membership_grade, etc.)
51
+ #
52
+ # @return [User] The configured user object.
53
+ #
54
+ def self.user(id:, **properties)
55
+ User.new(id: id, properties: properties)
56
+ end
57
+
58
+ #
59
+ # Instantiate an event to be used for the hackle sdk.
60
+ #
61
+ # The only required parameter is `key`, which must uniquely identify each event.
62
+ #
63
+ # @example
64
+ # Hackle.event(key: 'purchase')
65
+ # Hackle.event(key: 'purchase', value: 42000.0, app_version: '1.0.1', payment_method: 'CARD' )
66
+ #
67
+ # @param key [String] The unique key of the events.
68
+ # @param value [Float] Optional numeric value of the events (e.g. purchase_amount, quantity, etc.)
69
+ # @param properties Additional properties of the events (e.g. app_version, os_type, etc.)
70
+ #
71
+ # @return [Event] The configured event object.
72
+ #
73
+ def self.event(key:, value: nil, **properties)
74
+ Event.new(key: key, value: value, properties: properties)
75
+ end
76
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hackle/decision/bucketer'
4
+ require 'hackle/decision/decider'
5
+
6
+ require 'hackle/events/user_event'
7
+ require 'hackle/events/event_dispatcher'
8
+ require 'hackle/events/event_processor'
9
+
10
+ require 'hackle/http/http'
11
+
12
+ require 'hackle/models/bucket'
13
+ require 'hackle/models/event'
14
+ require 'hackle/models/event_type'
15
+ require 'hackle/models/experiment'
16
+ require 'hackle/models/slot'
17
+ require 'hackle/models/user'
18
+ require 'hackle/models/variation'
19
+
20
+ require 'hackle/workspaces/http_workspace_fetcher'
21
+ require 'hackle/workspaces/polling_workspace_fetcher'
22
+ require 'hackle/workspaces/workspace'
23
+
24
+ module Hackle
25
+
26
+ #
27
+ # A client for Hackle API.
28
+ #
29
+ class Client
30
+
31
+ #
32
+ # Initializes a Hackle client.
33
+ #
34
+ # @param config [Config]
35
+ # @param workspace_fetcher [PollingWorkspaceFetcher]
36
+ # @param event_processor [EventProcessor]
37
+ # @param decider [Decider]
38
+ #
39
+ def initialize(config:, workspace_fetcher:, event_processor:, decider:)
40
+ @logger = config.logger
41
+
42
+ # @type [PollingWorkspaceFetcher]
43
+ @workspace_fetcher = workspace_fetcher
44
+
45
+ # @type [EventProcessor]
46
+ @event_processor = event_processor
47
+
48
+ # @type [Decider]
49
+ @decider = decider
50
+ end
51
+
52
+ #
53
+ # Decide the variation to expose to the user for experiment.
54
+ #
55
+ # This method return the control variation 'A' if:
56
+ # - The experiment key is invalid
57
+ # - The experiment has not started yet
58
+ # - The user is not allocated to the experiment
59
+ # - The decided variation has been dropped
60
+ #
61
+ # @param experiment_key [Integer] The unique key of the experiment. MUST NOT be nil.
62
+ # @param user [User] the user to participate in the experiment. MUST NOT be nil.
63
+ # @param default_variation [String] The default variation of the experiment.
64
+ #
65
+ # @return [String] The decided variation for the user, or default variation
66
+ #
67
+ def variation(experiment_key:, user:, default_variation: 'A')
68
+
69
+ return default_variation if experiment_key.nil? || !experiment_key.is_a?(Integer)
70
+ return default_variation if user.nil? || !user.is_a?(User) || !user.valid?
71
+
72
+ workspace = @workspace_fetcher.fetch
73
+ return default_variation if workspace.nil?
74
+
75
+ experiment = workspace.get_experiment(experiment_key: experiment_key)
76
+ return default_variation if experiment.nil?
77
+
78
+ decision = @decider.decide(experiment: experiment, user: user)
79
+ case decision
80
+ when Decision::NotAllocated
81
+ default_variation
82
+ when Decision::ForcedAllocated
83
+ decision.variation_key
84
+ when Decision::NaturalAllocated
85
+ exposure_event = UserEvent::Exposure.new(user: user, experiment: experiment, variation: decision.variation)
86
+ @event_processor.process(event: exposure_event)
87
+ decision.variation.key
88
+ else
89
+ default_variation
90
+ end
91
+
92
+ rescue => e
93
+ @logger.error { "Unexpected error while deciding variation for experiment[#{experiment_key}]. Returning default variation[#{default_variation}]: #{e.inspect}" }
94
+ default_variation
95
+ end
96
+
97
+ #
98
+ # Records the event that occurred by the user.
99
+ #
100
+ # @param event [Event] the event that occurred.
101
+ # @param user [User] the user that occurred the event.
102
+ #
103
+ def track(event:, user:)
104
+
105
+ return if event.nil? || !event.is_a?(Event) || !event.valid?
106
+ return if user.nil? || !user.is_a?(User) || !user.valid?
107
+
108
+ workspace = @workspace_fetcher.fetch
109
+ return if workspace.nil?
110
+
111
+ event_type = workspace.get_event_type(event_type_key: event.key)
112
+ track_event = UserEvent::Track.new(user: user, event_type: event_type, event: event)
113
+ @event_processor.process(event: track_event)
114
+
115
+ rescue => e
116
+ @logger.error { "Unexpected error while tracking event: #{e.inspect}" }
117
+ end
118
+
119
+ #
120
+ # Shutdown the background task and release the resources used for the background task.
121
+ #
122
+ def close
123
+ @workspace_fetcher.stop!
124
+ @event_processor.stop!
125
+ end
126
+ end
127
+ end
@@ -20,7 +20,7 @@ module Hackle
20
20
  end
21
21
 
22
22
  def self.default_event_uri
23
- 'https://events.hackle.io'
23
+ 'https://event.hackle.io'
24
24
  end
25
25
 
26
26
  def self.default_logger
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'murmurhash3'
4
+
5
+ module Hackle
6
+ class Bucketer
7
+
8
+ # @param bucket [Bucket]
9
+ # @param user [User]
10
+ #
11
+ # @return [Slot, nil]
12
+ def bucketing(bucket:, user:)
13
+ slot_number = calculate_slot_number(
14
+ seed: bucket.seed,
15
+ slot_size: bucket.slot_size,
16
+ user_id: user.id
17
+ )
18
+ bucket.get_slot(slot_number: slot_number)
19
+ end
20
+
21
+ # @param seed [Integer]
22
+ # @param slot_size [Integer]
23
+ # @param user_id [String]
24
+ #
25
+ # @return [Integer]
26
+ def calculate_slot_number(seed:, slot_size:, user_id:)
27
+ hash_value = hash(data: user_id, seed: seed)
28
+ hash_value.abs % slot_size
29
+ end
30
+
31
+ # @param data [String]
32
+ # @param seed [Integer]
33
+ #
34
+ # @return [Integer]
35
+ def hash(data:, seed:)
36
+ unsigned_value = MurmurHash3::V32.str_hash(data, seed)
37
+ if (unsigned_value & 0x80000000).zero?
38
+ unsigned_value
39
+ else
40
+ -((unsigned_value ^ 0xFFFFFFFF) + 1)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackle
4
+ class Decision
5
+
6
+ class NotAllocated < Decision
7
+ end
8
+
9
+ class ForcedAllocated < Decision
10
+ # @return [String]
11
+ attr_reader :variation_key
12
+
13
+ # @param variation_key [String]
14
+ def initialize(variation_key:)
15
+ @variation_key = variation_key
16
+ end
17
+ end
18
+
19
+ class NaturalAllocated < Decision
20
+ # @return [Variation]
21
+ attr_reader :variation
22
+
23
+ # @param variation [Variation]
24
+ def initialize(variation:)
25
+ @variation = variation
26
+ end
27
+ end
28
+ end
29
+
30
+ class Decider
31
+ def initialize
32
+ @bucketer = Bucketer.new
33
+ end
34
+
35
+ # @param experiment [Experiment]
36
+ # @param user [User]
37
+ #
38
+ # @return [Decision]
39
+ def decide(experiment:, user:)
40
+ case experiment
41
+ when Experiment::Completed
42
+ Decision::ForcedAllocated.new(variation_key: experiment.winner_variation_key)
43
+ when Experiment::Running
44
+ decide_running(running_experiment: experiment, user: user)
45
+ else
46
+ NotAllocated.new
47
+ end
48
+ end
49
+
50
+ # @param running_experiment [Experiment::Running]
51
+ # @param user [User]
52
+ #
53
+ # @return [Decision]
54
+ def decide_running(running_experiment:, user:)
55
+
56
+ overridden_variation = running_experiment.get_overridden_variation(user: user)
57
+ return Decision::ForcedAllocated.new(variation_key: overridden_variation.key) unless overridden_variation.nil?
58
+
59
+ allocated_slot = @bucketer.bucketing(bucket: running_experiment.bucket, user: user)
60
+ return Decision::NotAllocated.new if allocated_slot.nil?
61
+
62
+ allocated_variation = running_experiment.get_variation(variation_id: allocated_slot.variation_id)
63
+ return Decision::NotAllocated.new if allocated_variation.nil?
64
+ return Decision::NotAllocated.new if allocated_variation.dropped
65
+
66
+ Decision::NaturalAllocated.new(variation: allocated_variation)
67
+ end
68
+ end
69
+ end
@@ -6,10 +6,10 @@ module Hackle
6
6
  DEFAULT_DISPATCH_WORKER_SIZE = 2
7
7
  DEFAULT_DISPATCH_QUEUE_CAPACITY = 50
8
8
 
9
- def initialize(config, sdk_info)
9
+ def initialize(config:, sdk_info:)
10
10
  @logger = config.logger
11
- @client = HTTP.client(config.event_uri)
12
- @headers = HTTP.sdk_headers(sdk_info)
11
+ @client = HTTP.client(base_uri: config.event_uri)
12
+ @headers = HTTP.sdk_headers(sdk_info: sdk_info)
13
13
  @dispatcher_executor = Concurrent::ThreadPoolExecutor.new(
14
14
  min_threads: DEFAULT_DISPATCH_WORKER_SIZE,
15
15
  max_threads: DEFAULT_DISPATCH_WORKER_SIZE,
@@ -17,10 +17,10 @@ module Hackle
17
17
  )
18
18
  end
19
19
 
20
- def dispatch(events)
21
- payload = create_payload(events)
20
+ def dispatch(events:)
21
+ payload = create_payload(events: events)
22
22
  begin
23
- @dispatcher_executor.post { dispatch_payload(payload) }
23
+ @dispatcher_executor.post { dispatch_payload(payload: payload) }
24
24
  rescue Concurrent::RejectedExecutionError
25
25
  @logger.warn { 'Dispatcher executor queue is full. Event dispatch rejected' }
26
26
  end
@@ -35,7 +35,7 @@ module Hackle
35
35
 
36
36
  private
37
37
 
38
- def dispatch_payload(payload)
38
+ def dispatch_payload(payload:)
39
39
  request = Net::HTTP::Post.new('/api/v1/events', @headers)
40
40
  request.content_type = 'application/json'
41
41
  request.body = payload.to_json
@@ -43,19 +43,19 @@ module Hackle
43
43
  response = @client.request(request)
44
44
 
45
45
  status_code = response.code.to_i
46
- HTTP.check_successful(status_code)
46
+ HTTP.check_successful(status_code: status_code)
47
47
  rescue => e
48
48
  @logger.error { "Failed to dispatch events: #{e.inspect}" }
49
49
  end
50
50
 
51
- def create_payload(events)
51
+ def create_payload(events:)
52
52
  exposure_events = []
53
53
  track_events = []
54
54
  events.each do |event|
55
55
  case event
56
- when Event::Exposure
56
+ when UserEvent::Exposure
57
57
  exposure_events << create_exposure_event(event)
58
- when Event::Track
58
+ when UserEvent::Track
59
59
  track_events << create_track_event(event)
60
60
  end
61
61
  end
@@ -65,24 +65,31 @@ module Hackle
65
65
  }
66
66
  end
67
67
 
68
- def create_exposure_event(event)
68
+ #
69
+ # @param exposure [UserEvent::Exposure]
70
+ #
71
+ def create_exposure_event(exposure)
69
72
  {
70
- timestamp: event.timestamp,
71
- userId: event.user_id,
72
- experimentId: event.experiment.id,
73
- experimentKey: event.experiment.key,
74
- variationId: event.variation.id,
75
- variationKey: event.variation.key
73
+ timestamp: exposure.timestamp,
74
+ userId: exposure.user.id,
75
+ experimentId: exposure.experiment.id,
76
+ experimentKey: exposure.experiment.key,
77
+ variationId: exposure.variation.id,
78
+ variationKey: exposure.variation.key
76
79
  }
77
80
  end
78
81
 
79
- def create_track_event(event)
82
+ #
83
+ # @param track [UserEvent::Track]
84
+ #
85
+ def create_track_event(track)
80
86
  {
81
- timestamp: event.timestamp,
82
- userId: event.user_id,
83
- eventTypeId: event.event_type.id,
84
- eventTypeKey: event.event_type.key,
85
- value: event.value
87
+ timestamp: track.timestamp,
88
+ userId: track.user.id,
89
+ eventTypeId: track.event_type.id,
90
+ eventTypeKey: track.event_type.key,
91
+ value: track.event.value,
92
+ properties: track.event.properties
86
93
  }
87
94
  end
88
95
  end