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,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'hackle/internal/model/decision_reason'
|
|
4
|
+
require 'hackle/internal/evaluation/evaluator/evaluator'
|
|
5
|
+
require 'hackle/internal/evaluation/evaluator/contextual/contextual_evaluator'
|
|
6
|
+
|
|
7
|
+
module Hackle
|
|
8
|
+
class ExperimentEvaluator
|
|
9
|
+
include ContextualEvaluator
|
|
10
|
+
|
|
11
|
+
# @param flow_factory [ExperimentEvaluationFlowFactory]
|
|
12
|
+
def initialize(flow_factory:)
|
|
13
|
+
# @type [ExperimentEvaluationFlowFactory]
|
|
14
|
+
@flow_factory = flow_factory
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def supports?(request)
|
|
18
|
+
request.is_a?(ExperimentRequest)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param request [ExperimentRequest]
|
|
22
|
+
# @param context [EvaluatorContext]
|
|
23
|
+
# @return [ExperimentEvaluation]
|
|
24
|
+
def evaluate_internal(request, context)
|
|
25
|
+
evaluation_flow = @flow_factory.get(request.experiment.type)
|
|
26
|
+
evaluation = evaluation_flow.evaluate(request, context)
|
|
27
|
+
return evaluation unless evaluation.nil?
|
|
28
|
+
|
|
29
|
+
ExperimentEvaluation.create_default(request, context, DecisionReason::TRAFFIC_NOT_ALLOCATED)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class ExperimentRequest < EvaluatorRequest
|
|
34
|
+
# @return [Experiment]
|
|
35
|
+
attr_reader :experiment
|
|
36
|
+
|
|
37
|
+
# @return [String]
|
|
38
|
+
attr_reader :default_variation_key
|
|
39
|
+
|
|
40
|
+
# @param key [EvaluatorKey]
|
|
41
|
+
# @param workspace [Workspace]
|
|
42
|
+
# @param user [HackleUser]
|
|
43
|
+
# @param experiment [Experiment]
|
|
44
|
+
# @param default_variation_key [String]
|
|
45
|
+
def initialize(key:, workspace:, user:, experiment:, default_variation_key:)
|
|
46
|
+
super(key: key, workspace: workspace, user: user)
|
|
47
|
+
@experiment = experiment
|
|
48
|
+
@default_variation_key = default_variation_key
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param workspace [Workspace]
|
|
52
|
+
# @param user [HackleUser]
|
|
53
|
+
# @param experiment [Experiment]
|
|
54
|
+
# @param default_variation_key [String]
|
|
55
|
+
# @return [Hackle::ExperimentRequest]
|
|
56
|
+
def self.create(workspace, user, experiment, default_variation_key)
|
|
57
|
+
ExperimentRequest.new(
|
|
58
|
+
key: EvaluatorKey.new(type: 'EXPERIMENT', id: experiment.id),
|
|
59
|
+
workspace: workspace,
|
|
60
|
+
user: user,
|
|
61
|
+
experiment: experiment,
|
|
62
|
+
default_variation_key: default_variation_key
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @param request [EvaluatorRequest]
|
|
67
|
+
# @param experiment [Experiment]
|
|
68
|
+
# @return [Hackle::ExperimentRequest]
|
|
69
|
+
def self.create_by(request, experiment)
|
|
70
|
+
ExperimentRequest.new(
|
|
71
|
+
key: EvaluatorKey.new(type: 'EXPERIMENT', id: experiment.id),
|
|
72
|
+
workspace: request.workspace,
|
|
73
|
+
user: request.user,
|
|
74
|
+
experiment: experiment,
|
|
75
|
+
default_variation_key: 'A'
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class ExperimentEvaluation < EvaluatorEvaluation
|
|
81
|
+
# @return [Experiment]
|
|
82
|
+
attr_reader :experiment
|
|
83
|
+
|
|
84
|
+
# @return [Integer, nil]
|
|
85
|
+
attr_reader :variation_id
|
|
86
|
+
|
|
87
|
+
# @return [String]
|
|
88
|
+
attr_reader :variation_key
|
|
89
|
+
|
|
90
|
+
# @return [ParameterConfiguration, nil]
|
|
91
|
+
attr_reader :config
|
|
92
|
+
|
|
93
|
+
# @param reason [String]
|
|
94
|
+
# @param target_evaluations [Array<EvaluatorEvaluation]
|
|
95
|
+
# @param experiment [Experiment]
|
|
96
|
+
# @param variation_id [Integer, nil]
|
|
97
|
+
# @param variation_key [String]
|
|
98
|
+
# @param config [ParameterConfiguration, nil]
|
|
99
|
+
def initialize(reason:, target_evaluations:, experiment:, variation_id:, variation_key:, config:)
|
|
100
|
+
super(reason: reason, target_evaluations: target_evaluations)
|
|
101
|
+
@experiment = experiment
|
|
102
|
+
@variation_id = variation_id
|
|
103
|
+
@variation_key = variation_key
|
|
104
|
+
@config = config
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [ParameterConfig]
|
|
108
|
+
def parameter_config
|
|
109
|
+
return ParameterConfig.empty if @config.nil?
|
|
110
|
+
|
|
111
|
+
ParameterConfig.new(@config.parameters)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @param reason [String]
|
|
115
|
+
# @return [Hackle::ExperimentEvaluation]
|
|
116
|
+
def with(reason)
|
|
117
|
+
ExperimentEvaluation.new(
|
|
118
|
+
reason: reason,
|
|
119
|
+
target_evaluations: target_evaluations,
|
|
120
|
+
experiment: experiment,
|
|
121
|
+
variation_id: variation_id,
|
|
122
|
+
variation_key: variation_key,
|
|
123
|
+
config: config
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @param request [ExperimentRequest]
|
|
128
|
+
# @param context [EvaluatorContext]
|
|
129
|
+
# @param variation [Variation]
|
|
130
|
+
# @param reason [String]
|
|
131
|
+
# @return [Hackle::ExperimentEvaluation]
|
|
132
|
+
def self.create(request, context, variation, reason)
|
|
133
|
+
configuration = configuration_or_nil(request.workspace, variation)
|
|
134
|
+
ExperimentEvaluation.new(
|
|
135
|
+
reason: reason,
|
|
136
|
+
target_evaluations: context.evaluations,
|
|
137
|
+
experiment: request.experiment,
|
|
138
|
+
variation_id: variation.id,
|
|
139
|
+
variation_key: variation.key,
|
|
140
|
+
config: configuration
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @param request [ExperimentRequest]
|
|
145
|
+
# @param context [EvaluatorContext]
|
|
146
|
+
# @param reason [String]
|
|
147
|
+
# @return [Hackle::ExperimentEvaluation]
|
|
148
|
+
def self.create_default(request, context, reason)
|
|
149
|
+
variation = request.experiment.get_variation_or_nil_by_key(request.default_variation_key)
|
|
150
|
+
return create(request, context, variation, reason) unless variation.nil?
|
|
151
|
+
|
|
152
|
+
ExperimentEvaluation.new(
|
|
153
|
+
reason: reason,
|
|
154
|
+
target_evaluations: context.evaluations,
|
|
155
|
+
experiment: request.experiment,
|
|
156
|
+
variation_id: nil,
|
|
157
|
+
variation_key: request.default_variation_key,
|
|
158
|
+
config: nil
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# @param workspace [Workspace]
|
|
163
|
+
# @param variation [Variation]
|
|
164
|
+
# @return [Hackle::ParameterConfiguration, nil]
|
|
165
|
+
def self.configuration_or_nil(workspace, variation)
|
|
166
|
+
parameter_configuration_id = variation.parameter_configuration_id
|
|
167
|
+
return nil if parameter_configuration_id.nil?
|
|
168
|
+
|
|
169
|
+
workspace.get_parameter_configuration_or_nil(parameter_configuration_id)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'hackle/internal/evaluation/evaluator/experiment/experiment_evaluator'
|
|
4
|
+
require 'hackle/internal/evaluation/flow/evaluation_flow'
|
|
5
|
+
require 'hackle/internal/evaluation/flow/flow_evaluator'
|
|
6
|
+
require 'hackle/internal/model/experiment'
|
|
7
|
+
require 'hackle/internal/model/decision_reason'
|
|
8
|
+
|
|
9
|
+
module Hackle
|
|
10
|
+
module ExperimentFlow
|
|
11
|
+
include EvaluationFlow
|
|
12
|
+
|
|
13
|
+
# @param request [ExperimentRequest]
|
|
14
|
+
# @param context [EvaluatorContext]
|
|
15
|
+
# @return [ExperimentEvaluation, nil]
|
|
16
|
+
def evaluate(request, context) end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
module ExperimentFlowEvaluator
|
|
20
|
+
include FlowEvaluator
|
|
21
|
+
|
|
22
|
+
# @param request [ExperimentRequest]
|
|
23
|
+
# @param context [EvaluatorContext]
|
|
24
|
+
# @param next_flow [ExperimentFlow]
|
|
25
|
+
# @return [ExperimentEvaluation, nil]
|
|
26
|
+
def evaluate(request, context, next_flow) end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class OverrideExperimentFlowEvaluator
|
|
30
|
+
include ExperimentFlowEvaluator
|
|
31
|
+
|
|
32
|
+
# @param override_resolver [ExperimentOverrideResolver]
|
|
33
|
+
def initialize(override_resolver:)
|
|
34
|
+
# @type [ExperimentOverrideResolver]
|
|
35
|
+
@override_resolver = override_resolver
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def evaluate(request, context, next_flow)
|
|
39
|
+
overridden_variation = @override_resolver.resolve_or_nil(request, context)
|
|
40
|
+
return next_flow.evaluate(request, context) if overridden_variation.nil?
|
|
41
|
+
|
|
42
|
+
case request.experiment.type
|
|
43
|
+
when ExperimentType::AB_TEST
|
|
44
|
+
ExperimentEvaluation.create(request, context, overridden_variation, DecisionReason::OVERRIDDEN)
|
|
45
|
+
when ExperimentType::FEATURE_FLAG
|
|
46
|
+
ExperimentEvaluation.create(request, context, overridden_variation, DecisionReason::INDIVIDUAL_TARGET_MATCH)
|
|
47
|
+
else
|
|
48
|
+
raise ArgumentError, "unsupported experiment type [#{request.experiment.type}]"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class DraftExperimentFlowEvaluator
|
|
54
|
+
include ExperimentFlowEvaluator
|
|
55
|
+
|
|
56
|
+
def evaluate(request, context, next_flow)
|
|
57
|
+
return next_flow.evaluate(request, context) if request.experiment.status != ExperimentStatus::DRAFT
|
|
58
|
+
|
|
59
|
+
ExperimentEvaluation.create_default(request, context, DecisionReason::EXPERIMENT_DRAFT)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class PausedExperimentFlowEvaluator
|
|
64
|
+
include ExperimentFlowEvaluator
|
|
65
|
+
|
|
66
|
+
def evaluate(request, context, next_flow)
|
|
67
|
+
return next_flow.evaluate(request, context) if request.experiment.status != ExperimentStatus::PAUSED
|
|
68
|
+
|
|
69
|
+
case request.experiment.type
|
|
70
|
+
when ExperimentType::AB_TEST
|
|
71
|
+
ExperimentEvaluation.create_default(request, context, DecisionReason::EXPERIMENT_PAUSED)
|
|
72
|
+
when ExperimentType::FEATURE_FLAG
|
|
73
|
+
ExperimentEvaluation.create_default(request, context, DecisionReason::FEATURE_FLAG_INACTIVE)
|
|
74
|
+
else
|
|
75
|
+
raise ArgumentError, "unsupported experiment type [#{request.experiment.type}]"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class CompletedExperimentFlowEvaluator
|
|
81
|
+
include ExperimentFlowEvaluator
|
|
82
|
+
|
|
83
|
+
def evaluate(request, context, next_flow)
|
|
84
|
+
return next_flow.evaluate(request, context) if request.experiment.status != ExperimentStatus::COMPLETED
|
|
85
|
+
|
|
86
|
+
winner_variation = request.experiment.winner_variation_or_nil
|
|
87
|
+
raise ArgumentError, "winner variation [#{request.experiment.id}]" if winner_variation.nil?
|
|
88
|
+
|
|
89
|
+
ExperimentEvaluation.create(request, context, winner_variation, DecisionReason::EXPERIMENT_COMPLETED)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
class TargetExperimentFlowEvaluator
|
|
94
|
+
include ExperimentFlowEvaluator
|
|
95
|
+
|
|
96
|
+
# @param target_determiner [ExperimentTargetDeterminer]
|
|
97
|
+
def initialize(target_determiner:)
|
|
98
|
+
# @type [ExperimentTargetDeterminer]
|
|
99
|
+
@target_determiner = target_determiner
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def evaluate(request, context, next_flow)
|
|
103
|
+
if request.experiment.type != ExperimentType::AB_TEST
|
|
104
|
+
raise ArgumentError, "experiment type must be AB_TEST [#{request.experiment.id}]"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
is_user_in_experiment_target = @target_determiner.user_in_experiment_target?(request, context)
|
|
108
|
+
if is_user_in_experiment_target
|
|
109
|
+
next_flow.evaluate(request, context)
|
|
110
|
+
else
|
|
111
|
+
ExperimentEvaluation.create_default(request, context, DecisionReason::NOT_IN_EXPERIMENT_TARGET)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
class TrafficAllocateExperimentFlowEvaluator
|
|
117
|
+
include ExperimentFlowEvaluator
|
|
118
|
+
|
|
119
|
+
# @param action_resolver [ExperimentActionResolver]
|
|
120
|
+
def initialize(action_resolver:)
|
|
121
|
+
# @type [ExperimentActionResolver]
|
|
122
|
+
@action_resolver = action_resolver
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def evaluate(request, context, _next_flow)
|
|
126
|
+
if request.experiment.status != ExperimentStatus::RUNNING
|
|
127
|
+
raise ArgumentError, "experiment status must be RUNNING [#{request.experiment.id}]"
|
|
128
|
+
end
|
|
129
|
+
if request.experiment.type != ExperimentType::AB_TEST
|
|
130
|
+
raise ArgumentError, "experiment type must be AB_TEST [#{request.experiment.id}]"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
variation = @action_resolver.resolve_or_nil(request, request.experiment.default_rule)
|
|
134
|
+
if variation.nil?
|
|
135
|
+
return ExperimentEvaluation.create_default(request, context, DecisionReason::TRAFFIC_NOT_ALLOCATED)
|
|
136
|
+
end
|
|
137
|
+
if variation.is_dropped
|
|
138
|
+
return ExperimentEvaluation.create_default(request, context, DecisionReason::VARIATION_DROPPED)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
ExperimentEvaluation.create(request, context, variation, DecisionReason::TRAFFIC_ALLOCATED)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
class TargetRuleExperimentFlowEvaluator
|
|
146
|
+
include ExperimentFlowEvaluator
|
|
147
|
+
|
|
148
|
+
# @param target_rule_determiner [ExperimentTargetRuleDeterminer]
|
|
149
|
+
# @param action_resolver [ExperimentActionResolver]
|
|
150
|
+
def initialize(target_rule_determiner:, action_resolver:)
|
|
151
|
+
# @type [ExperimentTargetRuleDeterminer]
|
|
152
|
+
@target_rule_determiner = target_rule_determiner
|
|
153
|
+
# @type [ExperimentActionResolver]
|
|
154
|
+
@action_resolver = action_resolver
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def evaluate(request, context, next_flow)
|
|
158
|
+
if request.experiment.status != ExperimentStatus::RUNNING
|
|
159
|
+
raise ArgumentError, "experiment status must be RUNNING [#{request.experiment.id}]"
|
|
160
|
+
end
|
|
161
|
+
if request.experiment.type != ExperimentType::FEATURE_FLAG
|
|
162
|
+
raise ArgumentError, "experiment type must be FEATURE_FLAG [#{request.experiment.id}]"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
return next_flow.evaluate(request, context) if request.user.identifiers[request.experiment.identifier_type].nil?
|
|
166
|
+
|
|
167
|
+
target_rule = @target_rule_determiner.determine_target_rule_or_nil(request, context)
|
|
168
|
+
return next_flow.evaluate(request, context) if target_rule.nil?
|
|
169
|
+
|
|
170
|
+
variation = @action_resolver.resolve_or_nil(request, target_rule.action)
|
|
171
|
+
raise ArgumentError, "feature flag must decide the variation [#{request.experiment.id}]" if variation.nil?
|
|
172
|
+
|
|
173
|
+
ExperimentEvaluation.create(request, context, variation, DecisionReason::TARGET_RULE_MATCH)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
class DefaultRuleExperimentFlowEvaluator
|
|
178
|
+
include ExperimentFlowEvaluator
|
|
179
|
+
|
|
180
|
+
# @param action_resolver [ExperimentActionResolver]
|
|
181
|
+
def initialize(action_resolver:)
|
|
182
|
+
# @type [ExperimentActionResolver]
|
|
183
|
+
@action_resolver = action_resolver
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def evaluate(request, context, _next_flow)
|
|
187
|
+
if request.experiment.status != ExperimentStatus::RUNNING
|
|
188
|
+
raise ArgumentError, "experiment status must be RUNNING [#{request.experiment.id}]"
|
|
189
|
+
end
|
|
190
|
+
if request.experiment.type != ExperimentType::FEATURE_FLAG
|
|
191
|
+
raise ArgumentError, "experiment type must be FEATURE_FLAG [#{request.experiment.id}]"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
if request.user.identifiers[request.experiment.identifier_type].nil?
|
|
195
|
+
return ExperimentEvaluation.create_default(request, context, DecisionReason::DEFAULT_RULE)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
variation = @action_resolver.resolve_or_nil(request, request.experiment.default_rule)
|
|
199
|
+
raise ArgumentError, "feature flag must decide the variation [#{request.experiment.id}]" if variation.nil?
|
|
200
|
+
|
|
201
|
+
ExperimentEvaluation.create(request, context, variation, DecisionReason::DEFAULT_RULE)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
class ContainerExperimentFlowEvaluator
|
|
206
|
+
include ExperimentFlowEvaluator
|
|
207
|
+
|
|
208
|
+
# @param container_resolver [ExperimentContainerResolver]
|
|
209
|
+
def initialize(container_resolver:)
|
|
210
|
+
# @type [ExperimentContainerResolver]
|
|
211
|
+
@container_resolver = container_resolver
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def evaluate(request, context, next_flow)
|
|
215
|
+
container_id = request.experiment.container_id
|
|
216
|
+
return next_flow.evaluate(request, context) if container_id.nil?
|
|
217
|
+
|
|
218
|
+
container = request.workspace.get_container_or_nil(container_id)
|
|
219
|
+
raise ArgumentError, "container [#{container_id}]" if container.nil?
|
|
220
|
+
|
|
221
|
+
is_user_in_container_group = @container_resolver.user_in_container_group?(request, container)
|
|
222
|
+
if is_user_in_container_group
|
|
223
|
+
next_flow.evaluate(request, context)
|
|
224
|
+
else
|
|
225
|
+
ExperimentEvaluation.create_default(request, context, DecisionReason::NOT_IN_MUTUAL_EXCLUSION_EXPERIMENT)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
class IdentifierExperimentFlowEvaluator
|
|
231
|
+
include ExperimentFlowEvaluator
|
|
232
|
+
|
|
233
|
+
def evaluate(request, context, next_flow)
|
|
234
|
+
if request.user.identifiers[request.experiment.identifier_type].nil?
|
|
235
|
+
ExperimentEvaluation.create_default(request, context, DecisionReason::IDENTIFIER_NOT_FOUND)
|
|
236
|
+
else
|
|
237
|
+
next_flow.evaluate(request, context)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'hackle/internal/model/action'
|
|
4
|
+
require 'hackle/internal/evaluation/bucketer/bucketer'
|
|
5
|
+
|
|
6
|
+
module Hackle
|
|
7
|
+
class ExperimentActionResolver
|
|
8
|
+
# @param bucketer [Bucketer]
|
|
9
|
+
def initialize(bucketer:)
|
|
10
|
+
# @type [Bucketer]
|
|
11
|
+
@bucketer = bucketer
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @param request [ExperimentRequest]
|
|
15
|
+
# @param action [Action]
|
|
16
|
+
# @return [Hackle::Variation, nil]
|
|
17
|
+
def resolve_or_nil(request, action)
|
|
18
|
+
case action.type
|
|
19
|
+
when ActionType::VARIATION
|
|
20
|
+
resolve_variation(request, action)
|
|
21
|
+
when ActionType::BUCKET
|
|
22
|
+
resolve_bucket(request, action)
|
|
23
|
+
else
|
|
24
|
+
raise ArgumentError, "unsupported ActionType [#{action.type}]"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# @param request [ExperimentRequest]
|
|
31
|
+
# @param action [Action]
|
|
32
|
+
# @return [Hackle::Variation]
|
|
33
|
+
def resolve_variation(request, action)
|
|
34
|
+
variation_id = action.variation_id
|
|
35
|
+
raise ArgumentError, "action variation [#{request.experiment.id}]" if variation_id.nil?
|
|
36
|
+
|
|
37
|
+
variation = request.experiment.get_variation_or_nil_by_id(variation_id)
|
|
38
|
+
raise ArgumentError, "variation [#{variation_id}]" if variation.nil?
|
|
39
|
+
|
|
40
|
+
variation
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param request [ExperimentRequest]
|
|
44
|
+
# @param action [Action]
|
|
45
|
+
# @return [Hackle::Variation, nil]
|
|
46
|
+
def resolve_bucket(request, action)
|
|
47
|
+
bucket_id = action.bucket_id
|
|
48
|
+
raise ArgumentError, "action bucket [#{request.experiment.id}]" if bucket_id.nil?
|
|
49
|
+
|
|
50
|
+
bucket = request.workspace.get_bucket_or_nil(bucket_id)
|
|
51
|
+
raise ArgumentError, "bucket [#{bucket_id}]" if bucket.nil?
|
|
52
|
+
|
|
53
|
+
identifier = request.user.identifiers[request.experiment.identifier_type]
|
|
54
|
+
return nil if identifier.nil?
|
|
55
|
+
|
|
56
|
+
slot = @bucketer.bucketing(bucket, identifier)
|
|
57
|
+
return nil if slot.nil?
|
|
58
|
+
|
|
59
|
+
request.experiment.get_variation_or_nil_by_id(slot.variation_id)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class ExperimentOverrideResolver
|
|
64
|
+
# @param target_matcher [TargetMatcher]
|
|
65
|
+
# @param action_resolver [ExperimentActionResolver]
|
|
66
|
+
|
|
67
|
+
def initialize(target_matcher:, action_resolver:)
|
|
68
|
+
# @type [TargetMatcher]
|
|
69
|
+
@target_matcher = target_matcher
|
|
70
|
+
# @type [ExperimentActionResolver]
|
|
71
|
+
@action_resolver = action_resolver
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @param request [ExperimentRequest]
|
|
75
|
+
# @param context [EvaluatorContext]
|
|
76
|
+
# @return [Hackle::Variation, nil]
|
|
77
|
+
def resolve_or_nil(request, context)
|
|
78
|
+
resolve_user_override(request) || resolve_segment_override(request, context)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# @param request [ExperimentRequest]
|
|
84
|
+
# @return [Hackle::Variation, nil]
|
|
85
|
+
def resolve_user_override(request)
|
|
86
|
+
identifier = request.user.identifiers[request.experiment.identifier_type]
|
|
87
|
+
return nil if identifier.nil?
|
|
88
|
+
|
|
89
|
+
overridden_variation_id = request.experiment.user_overrides[identifier]
|
|
90
|
+
return nil if overridden_variation_id.nil?
|
|
91
|
+
|
|
92
|
+
request.experiment.get_variation_or_nil_by_id(overridden_variation_id)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @param request [ExperimentRequest]
|
|
96
|
+
# @param context [EvaluatorContext]
|
|
97
|
+
# @return [Hackle::Variation, nil]
|
|
98
|
+
def resolve_segment_override(request, context)
|
|
99
|
+
overridden_rule = request.experiment.segment_overrides.find do |it|
|
|
100
|
+
@target_matcher.matches(request, context, it.target)
|
|
101
|
+
end
|
|
102
|
+
return nil if overridden_rule.nil?
|
|
103
|
+
|
|
104
|
+
@action_resolver.resolve_or_nil(request, overridden_rule.action)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
class ExperimentContainerResolver
|
|
109
|
+
# @param bucketer [Bucketer]
|
|
110
|
+
def initialize(bucketer:)
|
|
111
|
+
# @type [Bucketer]
|
|
112
|
+
@bucketer = bucketer
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @param request [ExperimentRequest]
|
|
116
|
+
# @param container [Container]
|
|
117
|
+
def user_in_container_group?(request, container)
|
|
118
|
+
|
|
119
|
+
identifier = request.user.identifiers[request.experiment.identifier_type]
|
|
120
|
+
return false if identifier.nil?
|
|
121
|
+
|
|
122
|
+
bucket = request.workspace.get_bucket_or_nil(container.bucket_id)
|
|
123
|
+
raise ArgumentError, "bucket [#{container.bucket_id}]" if bucket.nil?
|
|
124
|
+
|
|
125
|
+
slot = @bucketer.bucketing(bucket, identifier)
|
|
126
|
+
return false if slot.nil?
|
|
127
|
+
|
|
128
|
+
group = container.get_group_or_nil(slot.variation_id)
|
|
129
|
+
raise ArgumentError, "container group [#{slot.variation_id}]" if group.nil?
|
|
130
|
+
|
|
131
|
+
group.experiments.include?(request.experiment.id)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
class ExperimentTargetDeterminer
|
|
136
|
+
|
|
137
|
+
# @param target_matcher [TargetMatcher]
|
|
138
|
+
def initialize(target_matcher:)
|
|
139
|
+
# @type [TargetMatcher]
|
|
140
|
+
@target_matcher = target_matcher
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @param request [ExperimentRequest]
|
|
144
|
+
# @param context [EvaluatorContext]
|
|
145
|
+
def user_in_experiment_target?(request, context)
|
|
146
|
+
return true if request.experiment.target_audiences.empty?
|
|
147
|
+
|
|
148
|
+
request.experiment.target_audiences.any? { |it| @target_matcher.matches(request, context, it) }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
class ExperimentTargetRuleDeterminer
|
|
153
|
+
# @param target_matcher [TargetMatcher]
|
|
154
|
+
def initialize(target_matcher:)
|
|
155
|
+
# @type [TargetMatcher]
|
|
156
|
+
@target_matcher = target_matcher
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @param request [ExperimentRequest]
|
|
160
|
+
# @param context [EvaluatorContext]
|
|
161
|
+
# @return [Hackle::TargetRule, nil]
|
|
162
|
+
def determine_target_rule_or_nil(request, context)
|
|
163
|
+
request.experiment.target_rules.find { |it| @target_matcher.matches(request, context, it.target) }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hackle
|
|
4
|
+
class RemoteConfigTargetRuleDeterminer
|
|
5
|
+
|
|
6
|
+
# @param matcher [RemoteConfigTargetRuleMatcher]
|
|
7
|
+
def initialize(matcher:)
|
|
8
|
+
# @type [RemoteConfigTargetRuleMatcher]
|
|
9
|
+
@matcher = matcher
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @param request [RemoteConfigRequest]
|
|
13
|
+
# @param context [EvaluatorContext]
|
|
14
|
+
# @return [RemoteConfigTargetRule, nil]
|
|
15
|
+
def determine_or_nil(request, context)
|
|
16
|
+
request.parameter.target_rules.find { |it| @matcher.matches(request, context, it) }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class RemoteConfigTargetRuleMatcher
|
|
21
|
+
# @param target_matcher [TargetMatcher]
|
|
22
|
+
# @param bucketer [Bucketer]
|
|
23
|
+
def initialize(target_matcher:, bucketer:)
|
|
24
|
+
# @type [TargetMatcher]
|
|
25
|
+
@target_matcher = target_matcher
|
|
26
|
+
# @type [Bucketer]
|
|
27
|
+
@bucketer = bucketer
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param request [RemoteConfigRequest]
|
|
31
|
+
# @param context [EvaluatorContext]
|
|
32
|
+
# @param target_rule [RemoteConfigTargetRule]
|
|
33
|
+
# @return [boolean]
|
|
34
|
+
def matches(request, context, target_rule)
|
|
35
|
+
matches = @target_matcher.matches(request, context, target_rule.target)
|
|
36
|
+
return false unless matches
|
|
37
|
+
|
|
38
|
+
identifier = request.user.identifiers[request.parameter.identifier_type]
|
|
39
|
+
return false if identifier.nil?
|
|
40
|
+
|
|
41
|
+
bucket = request.workspace.get_bucket_or_nil(target_rule.bucket_id)
|
|
42
|
+
raise ArgumentError, "bucket [#{target_rule.bucket_id}]" if bucket.nil?
|
|
43
|
+
|
|
44
|
+
allocated = @bucketer.bucketing(bucket, identifier)
|
|
45
|
+
!allocated.nil?
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|