Skip to content

Commit 021451c

Browse files
authored
feat: Add support for client-side prerequisite events (#299)
1 parent a99ce05 commit 021451c

11 files changed

+170
-74
lines changed

contract-tests/service.rb

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
'anonymous-redaction',
4545
'evaluation-hooks',
4646
'omit-anonymous-contexts',
47+
'client-prereq-events',
4748
],
4849
}.to_json
4950
end

lib/ldclient-rb/flags_state.rb

+1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def add_flag(flag_state, with_reasons, details_only_if_tracked)
3838
meta[:version] = flag_state[:version]
3939
end
4040

41+
meta[:prerequisites] = flag_state[:prerequisites] unless flag_state[:prerequisites].nil? || flag_state[:prerequisites].empty?
4142
meta[:variation] = flag_state[:variation] unless flag_state[:variation].nil?
4243
meta[:trackEvents] = true if flag_state[:trackEvents]
4344
meta[:trackReason] = true if flag_state[:trackReason]

lib/ldclient-rb/impl/evaluator.rb

+16-4
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,16 @@ class EvaluatorState
3232
def initialize(original_flag)
3333
@prereq_stack = EvaluatorStack.new(original_flag.key)
3434
@segment_stack = EvaluatorStack.new(nil)
35+
@prerequisites = []
36+
@depth = 0
3537
end
3638

39+
def record_evaluated_prereq_key(key)
40+
@prerequisites.push(key) if @depth.zero?
41+
end
42+
43+
attr_accessor :depth
44+
attr_reader :prerequisites
3745
attr_reader :prereq_stack
3846
attr_reader :segment_stack
3947
end
@@ -135,7 +143,8 @@ def self.error_result(errorKind, value = nil)
135143
#
136144
# @param flag [LaunchDarkly::Impl::Model::FeatureFlag] the flag
137145
# @param context [LaunchDarkly::LDContext] the evaluation context
138-
# @return [EvalResult] the evaluation result
146+
# @return [Array<EvalResult, EvaluatorState>] the evaluation result and a state object that may be used for
147+
# inspecting the evaluation process
139148
def evaluate(flag, context)
140149
state = EvaluatorState.new(flag)
141150

@@ -145,11 +154,11 @@ def evaluate(flag, context)
145154
rescue EvaluationException => exn
146155
LaunchDarkly::Util.log_exception(@logger, "Unexpected error when evaluating flag #{flag.key}", exn)
147156
result.detail = EvaluationDetail.new(nil, nil, EvaluationReason::error(exn.error_kind))
148-
return result
157+
return result, state
149158
rescue => exn
150159
LaunchDarkly::Util.log_exception(@logger, "Unexpected error when evaluating flag #{flag.key}", exn)
151160
result.detail = EvaluationDetail.new(nil, nil, EvaluationReason::error(EvaluationReason::ERROR_EXCEPTION))
152-
return result
161+
return result, state
153162
end
154163

155164
unless result.big_segments_status.nil?
@@ -159,7 +168,7 @@ def evaluate(flag, context)
159168
detail.reason.with_big_segments_status(result.big_segments_status))
160169
end
161170
result.detail = detail
162-
result
171+
[result, state]
163172
end
164173

165174
# @param segment [LaunchDarkly::Impl::Model::Segment]
@@ -223,13 +232,16 @@ def self.make_big_segment_ref(segment) # method is visible for testing
223232
)
224233
end
225234

235+
state.record_evaluated_prereq_key(prereq_key)
226236
prereq_flag = @get_flag.call(prereq_key)
227237

228238
if prereq_flag.nil?
229239
@logger.error { "[LDClient] Could not retrieve prerequisite flag \"#{prereq_key}\" when evaluating \"#{flag.key}\"" }
230240
prereq_ok = false
231241
else
242+
state.depth += 1
232243
prereq_res = eval_internal(prereq_flag, context, eval_result, state)
244+
state.depth -= 1
233245
# Note that if the prerequisite flag is off, we don't consider it a match no matter what its
234246
# off variation was. But we still need to evaluate it in order to generate an event.
235247
if !prereq_flag.on || prereq_res.variation_index != prerequisite.variation

lib/ldclient-rb/ldclient.rb

+4-2
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,8 @@ def all_flags_state(context, options={})
546546
next
547547
end
548548
begin
549-
detail = @evaluator.evaluate(f, context).detail
549+
(eval_result, eval_state) = @evaluator.evaluate(f, context)
550+
detail = eval_result.detail
550551
rescue => exn
551552
detail = EvaluationDetail.new(nil, nil, EvaluationReason::error(EvaluationReason::ERROR_EXCEPTION))
552553
Util.log_exception(@config.logger, "Error evaluating flag \"#{k}\" in all_flags_state", exn)
@@ -558,6 +559,7 @@ def all_flags_state(context, options={})
558559
value: detail.value,
559560
variation: detail.variation_index,
560561
reason: detail.reason,
562+
prerequisites: eval_state.prerequisites,
561563
version: f[:version],
562564
trackEvents: f[:trackEvents] || requires_experiment_data,
563565
trackReason: requires_experiment_data,
@@ -705,7 +707,7 @@ def evaluate_internal(key, context, default, with_reasons)
705707
end
706708

707709
begin
708-
res = @evaluator.evaluate(feature, context)
710+
(res, _) = @evaluator.evaluate(feature, context)
709711
unless res.prereq_evals.nil?
710712
res.prereq_evals.each do |prereq_eval|
711713
record_prereq_flag_eval(prereq_eval.prereq_flag, prereq_eval.prereq_of_flag, context, prereq_eval.detail, with_reasons)

spec/impl/evaluator_big_segments_spec.rb

+7-7
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ module Impl
1919
.with_segment(segment)
2020
.build
2121
flag = Flags.boolean_flag_with_clauses(Clauses.match_segment(segment))
22-
result = e.evaluate(flag, user_context)
22+
(result, _) = e.evaluate(flag, user_context)
2323
expect(result.detail.value).to be false
2424
expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::NOT_CONFIGURED)
2525
end
@@ -35,7 +35,7 @@ module Impl
3535
.with_segment(segment)
3636
.build
3737
flag = Flags.boolean_flag_with_clauses(Clauses.match_segment(segment))
38-
result = e.evaluate(flag, user_context)
38+
(result, _) = e.evaluate(flag, user_context)
3939
expect(result.detail.value).to be false
4040
expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::NOT_CONFIGURED)
4141
end
@@ -52,7 +52,7 @@ module Impl
5252
.with_big_segment_for_context(user_context, segment, true)
5353
.build
5454
flag = Flags.boolean_flag_with_clauses(Clauses.match_segment(segment))
55-
result = e.evaluate(flag, user_context)
55+
(result, _) = e.evaluate(flag, user_context)
5656
expect(result.detail.value).to be true
5757
expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::HEALTHY)
5858
end
@@ -72,7 +72,7 @@ module Impl
7272
.with_big_segment_for_context(user_context, segment, nil)
7373
.build
7474
flag = Flags.boolean_flag_with_clauses(Clauses.match_segment(segment))
75-
result = e.evaluate(flag, user_context)
75+
(result, _) = e.evaluate(flag, user_context)
7676
expect(result.detail.value).to be true
7777
expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::HEALTHY)
7878
end
@@ -92,7 +92,7 @@ module Impl
9292
.with_big_segment_for_context(user_context, segment, false)
9393
.build
9494
flag = Flags.boolean_flag_with_clauses(Clauses.match_segment(segment))
95-
result = e.evaluate(flag, user_context)
95+
(result, _) = e.evaluate(flag, user_context)
9696
expect(result.detail.value).to be false
9797
expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::HEALTHY)
9898
end
@@ -110,7 +110,7 @@ module Impl
110110
.with_big_segments_status(BigSegmentsStatus::STALE)
111111
.build
112112
flag = Flags.boolean_flag_with_clauses(Clauses.match_segment(segment))
113-
result = e.evaluate(flag, user_context)
113+
(result, _) = e.evaluate(flag, user_context)
114114
expect(result.detail.value).to be true
115115
expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::STALE)
116116
end
@@ -148,7 +148,7 @@ module Impl
148148
# The membership deliberately does not include segment1, because we want the first rule to be
149149
# a non-match so that it will continue on and check segment2 as well.
150150

151-
result = e.evaluate(flag, user_context)
151+
(result, _) = e.evaluate(flag, user_context)
152152
expect(result.detail.value).to be true
153153
expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::HEALTHY)
154154

spec/impl/evaluator_clause_spec.rb

+24-12
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,32 @@ module Impl
99
context = LDContext.create({ key: 'x', kind: 'user', name: 'Bob' })
1010
clause = { attribute: 'name', op: 'in', values: ['Bob'] }
1111
flag = Flags.boolean_flag_with_clauses(clause)
12-
expect(basic_evaluator.evaluate(flag, context).detail.value).to be true
12+
(result, _) = basic_evaluator.evaluate(flag, context)
13+
expect(result.detail.value).to be true
1314
end
1415

1516
it "can match custom attribute" do
1617
context = LDContext.create({ key: 'x', kind: 'user', name: 'Bob', legs: 4 })
1718
clause = { attribute: 'legs', op: 'in', values: [4] }
1819
flag = Flags.boolean_flag_with_clauses(clause)
19-
expect(basic_evaluator.evaluate(flag, context).detail.value).to be true
20+
(result, _) = basic_evaluator.evaluate(flag, context)
21+
expect(result.detail.value).to be true
2022
end
2123

2224
it "returns false for missing attribute" do
2325
context = LDContext.create({ key: 'x', kind: 'user', name: 'Bob' })
2426
clause = { attribute: 'legs', op: 'in', values: [4] }
2527
flag = Flags.boolean_flag_with_clauses(clause)
26-
expect(basic_evaluator.evaluate(flag, context).detail.value).to be false
28+
(result, _) = basic_evaluator.evaluate(flag, context)
29+
expect(result.detail.value).to be false
2730
end
2831

2932
it "returns false for unknown operator" do
3033
context = LDContext.create({ key: 'x', kind: 'user', name: 'Bob' })
3134
clause = { attribute: 'name', op: 'unknown', values: [4] }
3235
flag = Flags.boolean_flag_with_clauses(clause)
33-
expect(basic_evaluator.evaluate(flag, context).detail.value).to be false
36+
(result, _) = basic_evaluator.evaluate(flag, context)
37+
expect(result.detail.value).to be false
3438
end
3539

3640
it "does not stop evaluating rules after clause with unknown operator" do
@@ -40,14 +44,16 @@ module Impl
4044
clause1 = { attribute: 'name', op: 'in', values: ['Bob'] }
4145
rule1 = { clauses: [ clause1 ], variation: 1 }
4246
flag = Flags.boolean_flag_with_rules(rule0, rule1)
43-
expect(basic_evaluator.evaluate(flag, context).detail.value).to be true
47+
(result, _) = basic_evaluator.evaluate(flag, context)
48+
expect(result.detail.value).to be true
4449
end
4550

4651
it "can be negated" do
4752
context = LDContext.create({ key: 'x', kind: 'user', name: 'Bob' })
4853
clause = { attribute: 'name', op: 'in', values: ['Bob'], negate: true }
4954
flag = Flags.boolean_flag_with_clauses(clause)
50-
expect(basic_evaluator.evaluate(flag, context).detail.value).to be false
55+
(result, _) = basic_evaluator.evaluate(flag, context)
56+
expect(result.detail.value).to be false
5157
end
5258

5359
it "clause match uses context kind" do
@@ -59,9 +65,12 @@ module Impl
5965

6066
flag = Flags.boolean_flag_with_clauses(clause)
6167

62-
expect(basic_evaluator.evaluate(flag, context1).detail.value).to be true
63-
expect(basic_evaluator.evaluate(flag, context2).detail.value).to be false
64-
expect(basic_evaluator.evaluate(flag, context3).detail.value).to be true
68+
(result, _) = basic_evaluator.evaluate(flag, context1)
69+
expect(result.detail.value).to be true
70+
(result, _) = basic_evaluator.evaluate(flag, context2)
71+
expect(result.detail.value).to be false
72+
(result, _) = basic_evaluator.evaluate(flag, context3)
73+
expect(result.detail.value).to be true
6574
end
6675

6776
it "clause match by kind attribute" do
@@ -78,9 +87,12 @@ module Impl
7887

7988
flag = Flags.boolean_flag_with_clauses(clause)
8089

81-
expect(basic_evaluator.evaluate(flag, context1).detail.value).to be false
82-
expect(basic_evaluator.evaluate(flag, context2).detail.value).to be true
83-
expect(basic_evaluator.evaluate(flag, context3).detail.value).to be true
90+
(result, _) = basic_evaluator.evaluate(flag, context1)
91+
expect(result.detail.value).to be false
92+
(result, _) = basic_evaluator.evaluate(flag, context2)
93+
expect(result.detail.value).to be true
94+
(result, _) = basic_evaluator.evaluate(flag, context3)
95+
expect(result.detail.value).to be true
8496
end
8597
end
8698
end

spec/impl/evaluator_prereq_spec.rb

+17-9
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ module Impl
1818
context = LDContext.create({ key: 'x' })
1919
detail = EvaluationDetail.new('b', 1, EvaluationReason::prerequisite_failed('badfeature'))
2020
e = EvaluatorBuilder.new(logger).with_unknown_flag('badfeature').build
21-
result = e.evaluate(flag, context)
21+
(result, state) = e.evaluate(flag, context)
22+
expect(state.prerequisites).to eq(['badfeature'])
2223
expect(result.detail).to eq(detail)
2324
expect(result.prereq_evals).to eq(nil)
2425
end
@@ -36,9 +37,11 @@ module Impl
3637
)
3738
context = LDContext.create({ key: 'x' })
3839
e = EvaluatorBuilder.new(logger).with_unknown_flag('badfeature').build
39-
result1 = e.evaluate(flag, context)
40+
(result1, state1) = e.evaluate(flag, context)
41+
expect(state1.prerequisites).to eq(['badfeature'])
4042
expect(result1.detail.reason).to eq EvaluationReason::prerequisite_failed('badfeature')
41-
result2 = e.evaluate(flag, context)
43+
(result2, state2) = e.evaluate(flag, context)
44+
expect(state2.prerequisites).to eq(['badfeature'])
4245
expect(result2.detail).to be result1.detail
4346
end
4447

@@ -69,7 +72,8 @@ module Impl
6972
PrerequisiteEvalRecord.new(flag1, flag, EvaluationDetail.new('d', 0, EvaluationReason::fallthrough())),
7073
]
7174
e = EvaluatorBuilder.new(logger).with_flag(flag1).with_unknown_flag('feature2').build
72-
result = e.evaluate(flag, context)
75+
(result, state) = e.evaluate(flag, context)
76+
expect(state.prerequisites).to eq(['feature1'])
7377
expect(result.detail).to eq(detail)
7478
expect(result.prereq_evals).to eq(expected_prereqs)
7579
end
@@ -102,7 +106,8 @@ module Impl
102106
PrerequisiteEvalRecord.new(flag1, flag, EvaluationDetail.new(nil, nil, EvaluationReason::prerequisite_failed('feature2'))),
103107
]
104108
e = EvaluatorBuilder.new(logger).with_flag(flag1).with_unknown_flag('feature2').build
105-
result = e.evaluate(flag, context)
109+
(result, state) = e.evaluate(flag, context)
110+
expect(state.prerequisites).to eq(['feature1'])
106111
expect(result.detail).to eq(detail)
107112
expect(result.prereq_evals).to eq(expected_prereqs)
108113
end
@@ -136,7 +141,8 @@ module Impl
136141
PrerequisiteEvalRecord.new(flag1, flag, EvaluationDetail.new('e', 1, EvaluationReason::off)),
137142
]
138143
e = EvaluatorBuilder.new(logger).with_flag(flag1).build
139-
result = e.evaluate(flag, context)
144+
(result, state) = e.evaluate(flag, context)
145+
expect(state.prerequisites).to eq(['feature1'])
140146
expect(result.detail).to eq(detail)
141147
expect(result.prereq_evals).to eq(expected_prereqs)
142148
end
@@ -168,7 +174,8 @@ module Impl
168174
PrerequisiteEvalRecord.new(flag1, flag, EvaluationDetail.new('d', 0, EvaluationReason::fallthrough)),
169175
]
170176
e = EvaluatorBuilder.new(logger).with_flag(flag1).build
171-
result = e.evaluate(flag, context)
177+
(result, state) = e.evaluate(flag, context)
178+
expect(state.prerequisites).to eq(['feature1'])
172179
expect(result.detail).to eq(detail)
173180
expect(result.prereq_evals).to eq(expected_prereqs)
174181
end
@@ -200,7 +207,8 @@ module Impl
200207
PrerequisiteEvalRecord.new(flag1, flag, EvaluationDetail.new('e', 1, EvaluationReason::fallthrough)),
201208
]
202209
e = EvaluatorBuilder.new(logger).with_flag(flag1).build
203-
result = e.evaluate(flag, context)
210+
(result, state) = e.evaluate(flag, context)
211+
expect(state.prerequisites).to eq(['feature1'])
204212
expect(result.detail).to eq(detail)
205213
expect(result.prereq_evals).to eq(expected_prereqs)
206214
end
@@ -224,7 +232,7 @@ module Impl
224232
flags.each { |flag| builder.with_flag(flag) }
225233

226234
evaluator = builder.build
227-
result = evaluator.evaluate(flags[0], LDContext.with_key('user'))
235+
(result, _) = evaluator.evaluate(flags[0], LDContext.with_key('user'))
228236
reason = EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)
229237
expect(result.detail.reason).to eq(reason)
230238
end

0 commit comments

Comments
 (0)