hackle-ruby-sdk 0.0.1 → 1.0.0

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