hackle-ruby-sdk 1.0.0 → 2.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.
- checksums.yaml +4 -4
- data/lib/hackle/client.rb +186 -87
- data/lib/hackle/config.rb +59 -17
- data/lib/hackle/decision.rb +113 -0
- data/lib/hackle/event.rb +89 -0
- data/lib/hackle/internal/clock/clock.rb +47 -0
- data/lib/hackle/internal/concurrent/executors.rb +20 -0
- data/lib/hackle/internal/concurrent/schedule/scheduler.rb +12 -0
- data/lib/hackle/internal/concurrent/schedule/timer_scheduler.rb +30 -0
- data/lib/hackle/internal/config/parameter_config.rb +50 -0
- data/lib/hackle/internal/core/hackle_core.rb +182 -0
- data/lib/hackle/{decision → internal/evaluation/bucketer}/bucketer.rb +17 -15
- data/lib/hackle/internal/evaluation/evaluator/contextual/contextual_evaluator.rb +29 -0
- data/lib/hackle/internal/evaluation/evaluator/delegating/delegating_evaluator.rb +26 -0
- data/lib/hackle/internal/evaluation/evaluator/evaluator.rb +117 -0
- data/lib/hackle/internal/evaluation/evaluator/experiment/experiment_evaluation_flow_factory.rb +67 -0
- data/lib/hackle/internal/evaluation/evaluator/experiment/experiment_evaluator.rb +172 -0
- data/lib/hackle/internal/evaluation/evaluator/experiment/experiment_flow_evaluator.rb +241 -0
- data/lib/hackle/internal/evaluation/evaluator/experiment/experiment_resolver.rb +166 -0
- data/lib/hackle/internal/evaluation/evaluator/remoteconfig/remote_config_determiner.rb +48 -0
- data/lib/hackle/internal/evaluation/evaluator/remoteconfig/remote_config_evaluator.rb +174 -0
- data/lib/hackle/internal/evaluation/flow/evaluation_flow.rb +49 -0
- data/lib/hackle/internal/evaluation/flow/flow_evaluator.rb +11 -0
- data/lib/hackle/internal/evaluation/match/condition/condition_matcher.rb +11 -0
- data/lib/hackle/internal/evaluation/match/condition/condition_matcher_factory.rb +53 -0
- data/lib/hackle/internal/evaluation/match/condition/experiment/experiment_condition_matcher.rb +29 -0
- data/lib/hackle/internal/evaluation/match/condition/experiment/experiment_evaluator_matcher.rb +135 -0
- data/lib/hackle/internal/evaluation/match/condition/segment/segment_condition_matcher.rb +67 -0
- data/lib/hackle/internal/evaluation/match/condition/user/user_condition_matcher.rb +44 -0
- data/lib/hackle/internal/evaluation/match/operator/operator_matcher.rb +185 -0
- data/lib/hackle/internal/evaluation/match/operator/operator_matcher_factory.rb +31 -0
- data/lib/hackle/internal/evaluation/match/target/target_matcher.rb +31 -0
- data/lib/hackle/internal/evaluation/match/value/value_matcher.rb +96 -0
- data/lib/hackle/internal/evaluation/match/value/value_matcher_factory.rb +28 -0
- data/lib/hackle/internal/evaluation/match/value/value_operator_matcher.rb +59 -0
- data/lib/hackle/internal/event/user_event.rb +187 -0
- data/lib/hackle/internal/event/user_event_dispatcher.rb +156 -0
- data/lib/hackle/internal/event/user_event_factory.rb +58 -0
- data/lib/hackle/internal/event/user_event_processor.rb +181 -0
- data/lib/hackle/internal/http/http.rb +28 -0
- data/lib/hackle/internal/http/http_client.rb +48 -0
- data/lib/hackle/internal/identifiers/identifier_builder.rb +67 -0
- data/lib/hackle/internal/logger/logger.rb +31 -0
- data/lib/hackle/internal/model/action.rb +57 -0
- data/lib/hackle/internal/model/bucket.rb +58 -0
- data/lib/hackle/internal/model/container.rb +47 -0
- data/lib/hackle/internal/model/decision_reason.rb +31 -0
- data/lib/hackle/{models → internal/model}/event_type.rb +5 -8
- data/lib/hackle/internal/model/experiment.rb +194 -0
- data/lib/hackle/internal/model/parameter_configuration.rb +19 -0
- data/lib/hackle/internal/model/remote_config_parameter.rb +76 -0
- data/lib/hackle/internal/model/sdk.rb +23 -0
- data/lib/hackle/internal/model/segment.rb +61 -0
- data/lib/hackle/internal/model/target.rb +203 -0
- data/lib/hackle/internal/model/target_rule.rb +19 -0
- data/lib/hackle/internal/model/targeting.rb +45 -0
- data/lib/hackle/internal/model/value_type.rb +75 -0
- data/lib/hackle/internal/model/variation.rb +27 -0
- data/lib/hackle/internal/model/version.rb +153 -0
- data/lib/hackle/internal/properties/properties_builder.rb +101 -0
- data/lib/hackle/internal/user/hackle_user.rb +74 -0
- data/lib/hackle/internal/user/hackle_user_resolver.rb +27 -0
- data/lib/hackle/internal/workspace/http_workspace_fetcher.rb +50 -0
- data/lib/hackle/internal/workspace/polling_workspace_fetcher.rb +62 -0
- data/lib/hackle/internal/workspace/workspace.rb +353 -0
- data/lib/hackle/internal/workspace/workspace_fetcher.rb +18 -0
- data/lib/hackle/remote_config.rb +55 -0
- data/lib/hackle/user.rb +124 -0
- data/lib/hackle/version.rb +1 -11
- data/lib/hackle.rb +4 -69
- metadata +123 -53
- data/.gitignore +0 -11
- data/.rspec +0 -2
- data/.travis.yml +0 -7
- data/Gemfile +0 -6
- data/README.md +0 -33
- data/Rakefile +0 -6
- data/hackle-ruby-sdk.gemspec +0 -29
- data/lib/hackle/decision/decider.rb +0 -69
- data/lib/hackle/events/event_dispatcher.rb +0 -96
- data/lib/hackle/events/event_processor.rb +0 -126
- data/lib/hackle/events/user_event.rb +0 -61
- data/lib/hackle/http/http.rb +0 -37
- data/lib/hackle/models/bucket.rb +0 -26
- data/lib/hackle/models/event.rb +0 -26
- data/lib/hackle/models/experiment.rb +0 -69
- data/lib/hackle/models/slot.rb +0 -22
- data/lib/hackle/models/user.rb +0 -24
- data/lib/hackle/models/variation.rb +0 -21
- data/lib/hackle/workspaces/http_workspace_fetcher.rb +0 -24
- data/lib/hackle/workspaces/polling_workspace_fetcher.rb +0 -47
- data/lib/hackle/workspaces/workspace.rb +0 -100
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'hackle/internal/http/http'
|
|
5
|
+
|
|
6
|
+
module Hackle
|
|
7
|
+
class UserEventDispatcher
|
|
8
|
+
# @param http_client [HttpClient]
|
|
9
|
+
# @param executor [ThreadPoolExecutor]
|
|
10
|
+
# @param serializer [UserEventSerializer]
|
|
11
|
+
def initialize(http_client:, executor:, serializer:)
|
|
12
|
+
# @type [HttpClient]
|
|
13
|
+
@http_client = http_client
|
|
14
|
+
# @type [ThreadPoolExecutor]
|
|
15
|
+
@executor = executor
|
|
16
|
+
@serializer = serializer
|
|
17
|
+
@url = '/api/v2/events'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param http_client [HttpClient]
|
|
21
|
+
# @param executor [ThreadPoolExecutor]
|
|
22
|
+
def self.create(http_client:, executor:)
|
|
23
|
+
UserEventDispatcher.new(
|
|
24
|
+
http_client: http_client,
|
|
25
|
+
executor: executor,
|
|
26
|
+
serializer: UserEventSerializer.new
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param events [Array<UserEvent>]
|
|
31
|
+
def dispatch(events)
|
|
32
|
+
payload = @serializer.serialize(events)
|
|
33
|
+
begin
|
|
34
|
+
@executor.post { dispatch_internal(payload) }
|
|
35
|
+
rescue => e
|
|
36
|
+
Log.get.error { "Unexpected error while posting events: #{e.inspect}" }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def shutdown
|
|
41
|
+
@executor.shutdown
|
|
42
|
+
return if @executor.wait_for_termination(10)
|
|
43
|
+
|
|
44
|
+
Log.get.warn { 'Failed to dispatch previously submitted events' }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# @param payload [Hash]
|
|
50
|
+
def dispatch_internal(payload)
|
|
51
|
+
request = create_request(payload)
|
|
52
|
+
response = @http_client.execute(request)
|
|
53
|
+
handle_response(response)
|
|
54
|
+
rescue => e
|
|
55
|
+
Log.get.error { "Failed to dispatch events: #{e.inspect}" }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @param payload [Hash]
|
|
59
|
+
# @return [Net::HTTPRequest]
|
|
60
|
+
def create_request(payload)
|
|
61
|
+
request = Net::HTTP::Post.new(@url)
|
|
62
|
+
request.content_type = 'application/json'
|
|
63
|
+
request.body = payload.to_json
|
|
64
|
+
request
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @param response [Net::HTTPResponse]
|
|
68
|
+
def handle_response(response)
|
|
69
|
+
raise "http status code: #{response.code}" unless HTTP.successful? response
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class UserEventSerializer
|
|
74
|
+
# @param events [Array<UserEvent>]
|
|
75
|
+
# @return [Hash]
|
|
76
|
+
def serialize(events)
|
|
77
|
+
exposure_events = []
|
|
78
|
+
track_events = []
|
|
79
|
+
remote_config_events = []
|
|
80
|
+
|
|
81
|
+
events.each do |event|
|
|
82
|
+
exposure_events << exposure_event(event) if event.is_a? ExposureEvent
|
|
83
|
+
track_events << track_event(event) if event.is_a? TrackEvent
|
|
84
|
+
remote_config_events << remote_config_event(event) if event.is_a? RemoteConfigEvent
|
|
85
|
+
end
|
|
86
|
+
{
|
|
87
|
+
exposureEvents: exposure_events,
|
|
88
|
+
trackEvents: track_events,
|
|
89
|
+
remoteConfigEvents: remote_config_events
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @param event [ExposureEvent]
|
|
94
|
+
# @return [Hash]
|
|
95
|
+
def exposure_event(event)
|
|
96
|
+
{
|
|
97
|
+
insertId: event.insert_id,
|
|
98
|
+
timestamp: event.timestamp,
|
|
99
|
+
|
|
100
|
+
userId: event.user.identifiers['$id'],
|
|
101
|
+
identifiers: event.user.identifiers,
|
|
102
|
+
userProperties: event.user.properties,
|
|
103
|
+
hackleProperties: {},
|
|
104
|
+
|
|
105
|
+
experimentId: event.experiment.id,
|
|
106
|
+
experimentKey: event.experiment.key,
|
|
107
|
+
experimentType: event.experiment.type.name,
|
|
108
|
+
experimentVersion: event.experiment.version,
|
|
109
|
+
variationId: event.variation_id,
|
|
110
|
+
variationKey: event.variation_key,
|
|
111
|
+
decisionReason: event.decision_reason,
|
|
112
|
+
properties: event.properties
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @param event [TrackEvent]
|
|
117
|
+
# @return [Hash]
|
|
118
|
+
def track_event(event)
|
|
119
|
+
{
|
|
120
|
+
insertId: event.insert_id,
|
|
121
|
+
timestamp: event.timestamp,
|
|
122
|
+
|
|
123
|
+
userId: event.user.identifiers['$id'],
|
|
124
|
+
identifiers: event.user.identifiers,
|
|
125
|
+
userProperties: event.user.properties,
|
|
126
|
+
hackleProperties: {},
|
|
127
|
+
|
|
128
|
+
eventTypeId: event.event_type.id,
|
|
129
|
+
eventTypeKey: event.event_type.key,
|
|
130
|
+
value: event.event.value,
|
|
131
|
+
properties: event.event.properties
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @param event [RemoteConfigEvent]
|
|
136
|
+
# @return [Hash]
|
|
137
|
+
def remote_config_event(event)
|
|
138
|
+
{
|
|
139
|
+
insertId: event.insert_id,
|
|
140
|
+
timestamp: event.timestamp,
|
|
141
|
+
|
|
142
|
+
userId: event.user.identifiers['$id'],
|
|
143
|
+
identifiers: event.user.identifiers,
|
|
144
|
+
userProperties: event.user.properties,
|
|
145
|
+
hackleProperties: {},
|
|
146
|
+
|
|
147
|
+
parameterId: event.parameter.id,
|
|
148
|
+
parameterKey: event.parameter.key,
|
|
149
|
+
parameterType: event.parameter.type.name,
|
|
150
|
+
valueId: event.value_id,
|
|
151
|
+
decisionReason: event.decision_reason,
|
|
152
|
+
properties: event.properties
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'hackle/internal/evaluation/evaluator/experiment/experiment_evaluator'
|
|
4
|
+
|
|
5
|
+
module Hackle
|
|
6
|
+
class UserEventFactory
|
|
7
|
+
# @param clock [Clock]
|
|
8
|
+
def initialize(clock:)
|
|
9
|
+
# @type [Clock]
|
|
10
|
+
@clock = clock
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @param request [EvaluatorRequest]
|
|
14
|
+
# @param evaluation [EvaluatorEvaluation]
|
|
15
|
+
# @return [Array<UserEvent>]
|
|
16
|
+
def create(request, evaluation)
|
|
17
|
+
timestamp = @clock.current_millis
|
|
18
|
+
events = []
|
|
19
|
+
|
|
20
|
+
root_event = create_internal(request, evaluation, timestamp, PropertiesBuilder.new)
|
|
21
|
+
events << root_event unless root_event.nil?
|
|
22
|
+
|
|
23
|
+
evaluation.target_evaluations.each do |target_evaluation|
|
|
24
|
+
properties_builder = PropertiesBuilder.new
|
|
25
|
+
properties_builder.add('$targetingRootType', request.key.type)
|
|
26
|
+
properties_builder.add('$targetingRootId', request.key.id)
|
|
27
|
+
target_event = create_internal(request, target_evaluation, timestamp, properties_builder)
|
|
28
|
+
events << target_event unless target_event.nil?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
events
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# @param request [EvaluatorRequest]
|
|
37
|
+
# @param evaluation [EvaluatorEvaluation]
|
|
38
|
+
# @param timestamp [Integer]
|
|
39
|
+
# @param properties_builder [PropertiesBuilder]
|
|
40
|
+
# @return [UserEvent, nil]
|
|
41
|
+
def create_internal(request, evaluation, timestamp, properties_builder)
|
|
42
|
+
e = evaluation
|
|
43
|
+
case e
|
|
44
|
+
when ExperimentEvaluation
|
|
45
|
+
properties_builder.add('$parameterConfigurationId', e.config&.id)
|
|
46
|
+
properties_builder.add('$experiment_version', e.experiment.version)
|
|
47
|
+
properties_builder.add('$execution_version', e.experiment.execution_version)
|
|
48
|
+
UserEvent.exposure(e, properties_builder.build, request.user, timestamp)
|
|
49
|
+
when RemoteConfigEvaluation
|
|
50
|
+
properties_builder.add_all(e.properties)
|
|
51
|
+
UserEvent.remote_config(e, properties_builder.build, request.user, timestamp)
|
|
52
|
+
else
|
|
53
|
+
Log.get.error { "unsupported evaluator evaluation: #{e.class}" }
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'hackle/internal/logger/logger'
|
|
4
|
+
|
|
5
|
+
module Hackle
|
|
6
|
+
module UserEventProcessor
|
|
7
|
+
# @param event [UserEvent]
|
|
8
|
+
def process(event) end
|
|
9
|
+
|
|
10
|
+
def start
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def stop
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def resume
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# noinspection RubyTooManyInstanceVariablesInspection
|
|
21
|
+
class DefaultUserEventProcessor
|
|
22
|
+
include UserEventProcessor
|
|
23
|
+
# @param queue [SizedQueue]
|
|
24
|
+
# @param event_dispatcher [UserEventDispatcher]
|
|
25
|
+
# @param event_dispatch_size [Integer]
|
|
26
|
+
# @param flush_scheduler [Scheduler]
|
|
27
|
+
# @param flush_interval_seconds [Float]
|
|
28
|
+
# @param shutdown_timeout_seconds [Float]
|
|
29
|
+
def initialize(
|
|
30
|
+
queue:,
|
|
31
|
+
event_dispatcher:,
|
|
32
|
+
event_dispatch_size:,
|
|
33
|
+
flush_scheduler:,
|
|
34
|
+
flush_interval_seconds:,
|
|
35
|
+
shutdown_timeout_seconds:
|
|
36
|
+
)
|
|
37
|
+
# @type [SizedQueue]
|
|
38
|
+
@queue = queue
|
|
39
|
+
|
|
40
|
+
# @type [UserEventDispatcher]
|
|
41
|
+
@event_dispatcher = event_dispatcher
|
|
42
|
+
|
|
43
|
+
# @type [Integer]
|
|
44
|
+
@event_dispatch_size = event_dispatch_size
|
|
45
|
+
|
|
46
|
+
# @type [Scheduler]
|
|
47
|
+
@flush_scheduler = flush_scheduler
|
|
48
|
+
|
|
49
|
+
# @type [Float]
|
|
50
|
+
@flush_interval_seconds = flush_interval_seconds
|
|
51
|
+
|
|
52
|
+
# @type [Float]
|
|
53
|
+
@shutdown_timeout_seconds = shutdown_timeout_seconds
|
|
54
|
+
|
|
55
|
+
# @type [ScheduledJob, nil]
|
|
56
|
+
@flushing_job = nil
|
|
57
|
+
|
|
58
|
+
# @type [Thread, nil]
|
|
59
|
+
@consuming_task = nil
|
|
60
|
+
|
|
61
|
+
# @type [boolean]
|
|
62
|
+
@is_started = false
|
|
63
|
+
|
|
64
|
+
# @type [Array<UserEvent>]
|
|
65
|
+
@current_batch = []
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @param event [UserEvent]
|
|
69
|
+
def process(event)
|
|
70
|
+
produce(message: Message::Event.new(event))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def start
|
|
74
|
+
if @is_started
|
|
75
|
+
Log.get.info { "#{UserEventProcessor} is already started." }
|
|
76
|
+
return
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@consuming_task = Thread.new { consuming }
|
|
80
|
+
@flushing_job = @flush_scheduler.schedule_periodically(@flush_interval_seconds, -> { flush })
|
|
81
|
+
@is_started = true
|
|
82
|
+
Log.get.info { "#{UserEventProcessor} started. Flush event every #{@flush_interval_seconds} seconds." }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def stop
|
|
86
|
+
return unless @is_started
|
|
87
|
+
|
|
88
|
+
Log.get.info { "Shutting down #{UserEventProcessor}" }
|
|
89
|
+
|
|
90
|
+
@flushing_job&.cancel
|
|
91
|
+
|
|
92
|
+
produce(message: Message::Shutdown.new, non_block: false)
|
|
93
|
+
@consuming_task&.join(@shutdown_timeout_seconds)
|
|
94
|
+
|
|
95
|
+
@event_dispatcher.shutdown
|
|
96
|
+
|
|
97
|
+
@is_started = false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def resume
|
|
101
|
+
@consuming_task = Thread.new { consuming } if @consuming_task.nil? || !@consuming_task.alive?
|
|
102
|
+
|
|
103
|
+
@flushing_job&.cancel
|
|
104
|
+
@flushing_job = @flush_scheduler.schedule_periodically(@flush_interval_seconds, -> { flush })
|
|
105
|
+
|
|
106
|
+
@is_started = true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# @param message [Message]
|
|
112
|
+
# @param non_block [boolean]
|
|
113
|
+
def produce(message:, non_block: true)
|
|
114
|
+
@queue.push(message, non_block)
|
|
115
|
+
rescue ThreadError
|
|
116
|
+
Log.get.warn { 'Events are produced faster than can be consumed. Some events will be dropped.' }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def flush
|
|
120
|
+
produce(message: Message::Flush.new)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def consuming
|
|
124
|
+
loop do
|
|
125
|
+
message = @queue.pop
|
|
126
|
+
case message
|
|
127
|
+
when Message::Event
|
|
128
|
+
consume_event(message.event)
|
|
129
|
+
when Message::Flush
|
|
130
|
+
dispatch_events
|
|
131
|
+
when Message::Shutdown
|
|
132
|
+
break
|
|
133
|
+
else
|
|
134
|
+
Log.get.error { "Unsupported message type: #{message.class}" }
|
|
135
|
+
end
|
|
136
|
+
rescue => e
|
|
137
|
+
Log.get.error { "Unexpected error in event processor: #{e.inspect}" }
|
|
138
|
+
end
|
|
139
|
+
rescue => e
|
|
140
|
+
Log.get.error { "Unexpected error in event processor: #{e.inspect}" }
|
|
141
|
+
ensure
|
|
142
|
+
dispatch_events
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @param event [UserEvent]
|
|
146
|
+
def consume_event(event)
|
|
147
|
+
@current_batch << event
|
|
148
|
+
dispatch_events if @current_batch.size >= @event_dispatch_size
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def dispatch_events
|
|
152
|
+
return if @current_batch.empty?
|
|
153
|
+
|
|
154
|
+
begin
|
|
155
|
+
@event_dispatcher.dispatch(@current_batch)
|
|
156
|
+
@current_batch = []
|
|
157
|
+
rescue => e
|
|
158
|
+
Log.get.error { "Failed to dispatch events: #{e.inspect}" }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
class Message
|
|
163
|
+
class Event < Message
|
|
164
|
+
# @return [UserEvent]
|
|
165
|
+
attr_reader :event
|
|
166
|
+
|
|
167
|
+
# @param event [UserEvent]
|
|
168
|
+
def initialize(event)
|
|
169
|
+
super()
|
|
170
|
+
@event = event
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
class Flush < Message
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
class Shutdown < Message
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
|
|
5
|
+
module Hackle
|
|
6
|
+
module HTTP
|
|
7
|
+
|
|
8
|
+
def self.client(base_url:)
|
|
9
|
+
uri = URI.parse(base_url)
|
|
10
|
+
# noinspection RubyMismatchedArgumentType
|
|
11
|
+
client = Net::HTTP.new(uri.host, uri.port)
|
|
12
|
+
client.use_ssl = uri.scheme == 'https'
|
|
13
|
+
client.open_timeout = 5
|
|
14
|
+
client.read_timeout = 10
|
|
15
|
+
client
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param response [Net::HTTPResponse]
|
|
19
|
+
def self.successful?(response)
|
|
20
|
+
response.code.start_with?('2')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param response [Net::HTTPResponse]
|
|
24
|
+
def self.not_modified?(response)
|
|
25
|
+
response.code == '304'
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'hackle/internal/http/http'
|
|
4
|
+
require 'hackle/internal/clock/clock'
|
|
5
|
+
|
|
6
|
+
module Hackle
|
|
7
|
+
class HttpClient
|
|
8
|
+
|
|
9
|
+
# @param http [Net::HTTP]
|
|
10
|
+
# @param sdk [Sdk]
|
|
11
|
+
# @param clock [Clock]
|
|
12
|
+
def initialize(http:, sdk:, clock:)
|
|
13
|
+
# @type [Net::HTTP]
|
|
14
|
+
@http = http
|
|
15
|
+
@sdk = sdk
|
|
16
|
+
@clock = clock
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param base_url [String]
|
|
20
|
+
# @param sdk [Sdk]
|
|
21
|
+
# @param clock [Clock]
|
|
22
|
+
# @return [HttpClient]
|
|
23
|
+
def self.create(base_url:, sdk:, clock: SystemClock.instance)
|
|
24
|
+
HttpClient.new(
|
|
25
|
+
http: HTTP.client(base_url: base_url),
|
|
26
|
+
sdk: sdk,
|
|
27
|
+
clock: clock
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param request [Net::HTTPRequest]
|
|
32
|
+
# @return [Net::HTTPResponse]
|
|
33
|
+
def execute(request)
|
|
34
|
+
decorate(request)
|
|
35
|
+
@http.request(request)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# @param request [Net::HTTPRequest]
|
|
41
|
+
def decorate(request)
|
|
42
|
+
request['X-HACKLE-SDK-KEY'] = @sdk.key
|
|
43
|
+
request['X-HACKLE-SDK-NAME'] = @sdk.name
|
|
44
|
+
request['X-HACKLE-SDK-VERSION'] = @sdk.version
|
|
45
|
+
request['X-HACKLE-SDK-TIME'] = @clock.current_millis.to_s
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'hackle/internal/logger/logger'
|
|
4
|
+
|
|
5
|
+
module Hackle
|
|
6
|
+
class IdentifiersBuilder
|
|
7
|
+
def initialize
|
|
8
|
+
# @type [Hash{String => String}]
|
|
9
|
+
@identifiers = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @param identifier_type [String]
|
|
13
|
+
# @param identifier_value [String, nil]
|
|
14
|
+
# @return [Hackle::IdentifiersBuilder]
|
|
15
|
+
def add(identifier_type, identifier_value)
|
|
16
|
+
value = IdentifiersBuilder.sanitize_value_or_nil(identifier_value)
|
|
17
|
+
|
|
18
|
+
if valid_type?(identifier_type) && !value.nil?
|
|
19
|
+
@identifiers[identifier_type] = value
|
|
20
|
+
else
|
|
21
|
+
Log.get.warn { "Invalid user identifier [type=#{identifier_type}] value=#{identifier_value}]" }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param identifiers [Hash]
|
|
28
|
+
# @return [Hackle::IdentifiersBuilder]
|
|
29
|
+
def add_all(identifiers)
|
|
30
|
+
identifiers.each do |identifier_type, identifier_value|
|
|
31
|
+
add(identifier_type, identifier_value)
|
|
32
|
+
end
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Hash{String => String}]
|
|
37
|
+
def build
|
|
38
|
+
@identifiers.dup
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.sanitize_value_or_nil(identifier_value)
|
|
42
|
+
return nil if identifier_value.nil?
|
|
43
|
+
|
|
44
|
+
if identifier_value.is_a?(String) && !identifier_value.empty? && identifier_value.length <= MAX_IDENTIFIER_VALUE_LENGTH
|
|
45
|
+
return identifier_value
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
return identifier_value.to_s if identifier_value.is_a?(Numeric)
|
|
49
|
+
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
MAX_IDENTIFIER_TYPE_LENGTH = 128
|
|
56
|
+
MAX_IDENTIFIER_VALUE_LENGTH = 512
|
|
57
|
+
|
|
58
|
+
def valid_type?(identifier_type)
|
|
59
|
+
return false if identifier_type.nil?
|
|
60
|
+
return false unless identifier_type.is_a?(String)
|
|
61
|
+
return false if identifier_type.empty?
|
|
62
|
+
return false if identifier_type.length > MAX_IDENTIFIER_TYPE_LENGTH
|
|
63
|
+
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
module Hackle
|
|
6
|
+
|
|
7
|
+
class Log
|
|
8
|
+
|
|
9
|
+
attr_accessor :logger
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@logger = Logger.new($stdout)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
@instance = new
|
|
16
|
+
|
|
17
|
+
# @return [Log]
|
|
18
|
+
def self.instance
|
|
19
|
+
@instance
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.init(logger)
|
|
23
|
+
instance.logger = logger
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Logger]
|
|
27
|
+
def self.get
|
|
28
|
+
Log.instance.logger
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hackle
|
|
4
|
+
class Action
|
|
5
|
+
|
|
6
|
+
# @!attribute [r] type
|
|
7
|
+
# @return [ActionType]
|
|
8
|
+
# @!attribute [r] variation_id
|
|
9
|
+
# @return [Integer, nil]
|
|
10
|
+
# @!attribute [r] bucket_id
|
|
11
|
+
# @return [Integer, nil]
|
|
12
|
+
attr_reader :type, :variation_id, :bucket_id
|
|
13
|
+
|
|
14
|
+
# @param type [ActionType]
|
|
15
|
+
# @param variation_id [Integer, nil]
|
|
16
|
+
# @param bucket_id [Integer, nil]
|
|
17
|
+
def initialize(type:, variation_id: nil, bucket_id: nil)
|
|
18
|
+
@type = type
|
|
19
|
+
@variation_id = variation_id
|
|
20
|
+
@bucket_id = bucket_id
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class ActionType
|
|
25
|
+
# @!attribute [r] name
|
|
26
|
+
# @return [String]
|
|
27
|
+
attr_reader :name
|
|
28
|
+
|
|
29
|
+
# @param name [String]
|
|
30
|
+
def initialize(name)
|
|
31
|
+
@name = name
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_s
|
|
35
|
+
name
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
VARIATION = new('VARIATION')
|
|
39
|
+
BUCKET = new('BUCKET')
|
|
40
|
+
|
|
41
|
+
@types = {
|
|
42
|
+
'VARIATION' => VARIATION,
|
|
43
|
+
'BUCKET' => BUCKET
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
# @param name [String]
|
|
47
|
+
# @return [ActionType, nil]
|
|
48
|
+
def self.from_or_nil(name)
|
|
49
|
+
@types[name.upcase]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Array<ActionType>]
|
|
53
|
+
def self.values
|
|
54
|
+
@types.values
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hackle
|
|
4
|
+
|
|
5
|
+
class Bucket
|
|
6
|
+
|
|
7
|
+
# @!attribute [r] id
|
|
8
|
+
# @return [Integer]
|
|
9
|
+
# @!attribute [r] seed
|
|
10
|
+
# @return [Integer]
|
|
11
|
+
# @!attribute [r] slot_size
|
|
12
|
+
# @return [Integer]
|
|
13
|
+
# @!attribute [r] slots
|
|
14
|
+
# @return [Array<Slot>]
|
|
15
|
+
attr_accessor :id, :seed, :slot_size, :slots
|
|
16
|
+
|
|
17
|
+
# @param id [Integer]
|
|
18
|
+
# @param seed [Integer]
|
|
19
|
+
# @param slot_size [Integer]
|
|
20
|
+
# @param slots [Array<Slot>]
|
|
21
|
+
def initialize(id:, seed:, slot_size:, slots:)
|
|
22
|
+
@id = id
|
|
23
|
+
@seed = seed
|
|
24
|
+
@slot_size = slot_size
|
|
25
|
+
@slots = slots
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param slot_number [Integer]
|
|
29
|
+
# @return [Slot, nil]
|
|
30
|
+
def get_slot_or_nil(slot_number)
|
|
31
|
+
slots.each do |slot|
|
|
32
|
+
return slot if slot.contains?(slot_number)
|
|
33
|
+
end
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class Slot
|
|
39
|
+
# @!attribute [r] variation_id
|
|
40
|
+
# @return [Integer]
|
|
41
|
+
attr_reader :variation_id
|
|
42
|
+
|
|
43
|
+
# @param start_inclusive [Integer]
|
|
44
|
+
# @param end_exclusive [Integer]
|
|
45
|
+
# @param variation_id [Integer]
|
|
46
|
+
def initialize(start_inclusive:, end_exclusive:, variation_id:)
|
|
47
|
+
@start_inclusive = start_inclusive
|
|
48
|
+
@end_exclusive = end_exclusive
|
|
49
|
+
@variation_id = variation_id
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param slot_number [Integer]
|
|
53
|
+
# @return [boolean]
|
|
54
|
+
def contains?(slot_number)
|
|
55
|
+
@start_inclusive <= slot_number && slot_number < @end_exclusive
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|