flagnetic 0.1.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 +7 -0
- data/LICENSE +202 -0
- data/README.md +276 -0
- data/lib/flagnetic/backoff.rb +29 -0
- data/lib/flagnetic/changes.rb +88 -0
- data/lib/flagnetic/client.rb +609 -0
- data/lib/flagnetic/controller_helpers.rb +97 -0
- data/lib/flagnetic/detail.rb +73 -0
- data/lib/flagnetic/eval/evaluation.rb +382 -0
- data/lib/flagnetic/eval/instant.rb +71 -0
- data/lib/flagnetic/eval/murmur3.rb +70 -0
- data/lib/flagnetic/eval/operators.rb +225 -0
- data/lib/flagnetic/eval/regex.rb +647 -0
- data/lib/flagnetic/eval/ruleset.rb +267 -0
- data/lib/flagnetic/eval/semver.rb +80 -0
- data/lib/flagnetic/eval/types.rb +148 -0
- data/lib/flagnetic/eval/unicode_tables.rb +180 -0
- data/lib/flagnetic/eval.rb +38 -0
- data/lib/flagnetic/event_batch.rb +119 -0
- data/lib/flagnetic/exposures.rb +342 -0
- data/lib/flagnetic/fallback_chain.rb +230 -0
- data/lib/flagnetic/generations.rb +50 -0
- data/lib/flagnetic/global.rb +59 -0
- data/lib/flagnetic/local_overrides.rb +65 -0
- data/lib/flagnetic/logging.rb +62 -0
- data/lib/flagnetic/railtie.rb +146 -0
- data/lib/flagnetic/recorder.rb +135 -0
- data/lib/flagnetic/reporter.rb +94 -0
- data/lib/flagnetic/sse_parser.rb +94 -0
- data/lib/flagnetic/store.rb +34 -0
- data/lib/flagnetic/stream.rb +65 -0
- data/lib/flagnetic/summaries.rb +257 -0
- data/lib/flagnetic/testing.rb +160 -0
- data/lib/flagnetic/transport.rb +106 -0
- data/lib/flagnetic/uuid.rb +25 -0
- data/lib/flagnetic/version.rb +16 -0
- data/lib/flagnetic/worker.rb +270 -0
- data/lib/flagnetic.rb +34 -0
- metadata +79 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Flagnetic
|
|
6
|
+
module Eval
|
|
7
|
+
# SCHEMA_VERSION is the ruleset schema version this evaluator reads.
|
|
8
|
+
SCHEMA_VERSION = 1
|
|
9
|
+
|
|
10
|
+
# SCOPE is the only scope of a ruleset this evaluator reads: the
|
|
11
|
+
# document an SDK evaluates locally holds everything the environment
|
|
12
|
+
# decides, private segments included. Another scope is a document made
|
|
13
|
+
# for another reader.
|
|
14
|
+
SCOPE = "server"
|
|
15
|
+
|
|
16
|
+
# DecodeError is a document that is not a ruleset this evaluator can
|
|
17
|
+
# read.
|
|
18
|
+
class DecodeError < StandardError; end
|
|
19
|
+
|
|
20
|
+
# Ruleset is the complete evaluable configuration of one environment at
|
|
21
|
+
# one moment. Build it with Ruleset.decode. It is frozen and safe to
|
|
22
|
+
# share between threads.
|
|
23
|
+
class Ruleset
|
|
24
|
+
attr_reader :schema_version, :version, :scope, :environment, :unit_types, :attributes, :segments, :flags
|
|
25
|
+
|
|
26
|
+
# decode reads a ruleset document from JSON text. It raises
|
|
27
|
+
# DecodeError only when the document is not a ruleset this evaluator
|
|
28
|
+
# can read: malformed JSON, a field of the wrong JSON type, an
|
|
29
|
+
# unsupported schema_version or a scope other than server. Unknown
|
|
30
|
+
# fields are ignored and null reads as absent. Dangling references,
|
|
31
|
+
# invalid conditions, invalid experiment serves and constructs this
|
|
32
|
+
# evaluator does not know do not fail decoding; evaluating a flag that
|
|
33
|
+
# reaches one returns reason error with inconsistent_ruleset, so one
|
|
34
|
+
# defective flag never takes the others down.
|
|
35
|
+
def self.decode(json)
|
|
36
|
+
raise DecodeError, "decode ruleset: want JSON text, got #{json.class}" unless json.is_a?(String)
|
|
37
|
+
|
|
38
|
+
doc = JSON.parse(Values.utf8(json), freeze: true, max_nesting: 10_000)
|
|
39
|
+
new(**Document.ruleset(doc))
|
|
40
|
+
rescue JSON::ParserError, EncodingError => e
|
|
41
|
+
raise DecodeError, "decode ruleset: #{e.message}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private_class_method :new
|
|
45
|
+
|
|
46
|
+
def initialize(schema_version:, version:, scope:, environment:, unit_types:, attributes:, segments:, flags:)
|
|
47
|
+
if schema_version != SCHEMA_VERSION
|
|
48
|
+
raise DecodeError, "decode ruleset: unsupported schema_version #{schema_version}, " \
|
|
49
|
+
"this evaluator reads #{SCHEMA_VERSION}"
|
|
50
|
+
end
|
|
51
|
+
unless scope == SCOPE
|
|
52
|
+
raise DecodeError, "decode ruleset: scope #{scope.inspect}, this evaluator reads #{SCOPE.inspect} only"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@schema_version = schema_version
|
|
56
|
+
@version = version
|
|
57
|
+
@scope = scope
|
|
58
|
+
@environment = environment
|
|
59
|
+
@unit_types = unit_types
|
|
60
|
+
@attributes = attributes
|
|
61
|
+
@segments = segments
|
|
62
|
+
@flags = flags
|
|
63
|
+
@index = Index.new(self)
|
|
64
|
+
freeze
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# protected? reports whether the ruleset belongs to a protected
|
|
68
|
+
# environment. SDKs refuse local overrides there. A document without
|
|
69
|
+
# an environment (a hand-written offline file) is unprotected.
|
|
70
|
+
def protected?
|
|
71
|
+
environment&.protected == true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# flag returns the flag with key as the document has it, or nil. SDKs
|
|
75
|
+
# check a flag's type against the one requested.
|
|
76
|
+
def flag(key)
|
|
77
|
+
@index.flags[key]&.flag
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# unsupported_rules reports, by flag key, the constructs each flag
|
|
81
|
+
# holds that this evaluator does not know: an operator, a condition
|
|
82
|
+
# kind or a serve kind, typically sent by a server newer than the SDK.
|
|
83
|
+
# The constructs come in document order, each once; a flag holds what
|
|
84
|
+
# its rules hold and what the segments its rules name hold. A flag
|
|
85
|
+
# holding none is absent, so the report is empty for a ruleset this
|
|
86
|
+
# evaluator fully supports.
|
|
87
|
+
def unsupported_rules
|
|
88
|
+
@index.unsupported
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# evaluate computes one flag for a context. It never reads a clock,
|
|
92
|
+
# never records an exposure and never fails: problems are results with
|
|
93
|
+
# reason error.
|
|
94
|
+
def evaluate(flag_key, context = nil)
|
|
95
|
+
Evaluation.new(@index, Context.__send__(:coerce, context)).flag(flag_key)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# evaluate_all evaluates every flag of the ruleset for a context, keyed
|
|
99
|
+
# by flag key. Prerequisite results are memoized within the call.
|
|
100
|
+
def evaluate_all(context = nil)
|
|
101
|
+
e = Evaluation.new(@index, Context.__send__(:coerce, context))
|
|
102
|
+
flags.to_h { |f| [f.key, e.flag(f.key)] }.freeze
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Document reads the decoded JSON into frozen structs, refusing a field
|
|
107
|
+
# of the wrong JSON type the way the reference evaluator's typed
|
|
108
|
+
# decoding does. A missing or null field is its zero value.
|
|
109
|
+
module Document
|
|
110
|
+
INT64 = (-(2**63))..((2**63) - 1)
|
|
111
|
+
private_constant :INT64
|
|
112
|
+
|
|
113
|
+
module_function
|
|
114
|
+
|
|
115
|
+
def ruleset(doc)
|
|
116
|
+
d = object(doc, "ruleset")
|
|
117
|
+
{
|
|
118
|
+
schema_version: int(d["schema_version"], "schema_version"),
|
|
119
|
+
version: int(d["version"], "version"),
|
|
120
|
+
scope: string(d["scope"], "scope"),
|
|
121
|
+
environment: d["environment"].nil? ? nil : environment(d["environment"]),
|
|
122
|
+
unit_types: list(d["unit_types"], "unit_types") { |u, p| string(u, p) },
|
|
123
|
+
attributes: list(d["attributes"], "attributes") { |a, p| attribute(a, p) },
|
|
124
|
+
segments: list(d["segments"], "segments") { |s, p| segment(s, p) },
|
|
125
|
+
flags: list(d["flags"], "flags") { |f, p| flag(f, p) }
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def environment(value)
|
|
130
|
+
e = object(value, "environment")
|
|
131
|
+
Environment.new(key: string(e["key"], "environment.key"),
|
|
132
|
+
protected: bool(e["protected"], "environment.protected")).freeze
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def attribute(value, path)
|
|
136
|
+
a = object(value, path)
|
|
137
|
+
Attribute.new(key: string(a["key"], "#{path}.key"), type: string(a["type"], "#{path}.type")).freeze
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def segment(value, path)
|
|
141
|
+
s = object(value, path)
|
|
142
|
+
Segment.new(key: string(s["key"], "#{path}.key"), private: bool(s["private"], "#{path}.private"),
|
|
143
|
+
conditions: list(s["conditions"], "#{path}.conditions") { |c, p| condition(c, p) }).freeze
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def condition(value, path)
|
|
147
|
+
c = object(value, path)
|
|
148
|
+
fields = %w[kind attribute op segment flag variation].to_h do |k|
|
|
149
|
+
[k.to_sym, string(c[k], "#{path}.#{k}")]
|
|
150
|
+
end
|
|
151
|
+
Condition.new(value: condition_value(c["value"], "#{path}.value"), **fields).freeze
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# condition_value refuses a number beyond the range of a double, which
|
|
155
|
+
# the reference evaluator cannot decode into a condition value.
|
|
156
|
+
# Variation values stay raw JSON there and are not checked.
|
|
157
|
+
def condition_value(value, path)
|
|
158
|
+
case value
|
|
159
|
+
when Array then value.each_with_index { |e, i| condition_value(e, "#{path}[#{i}]") }
|
|
160
|
+
when Hash then value.each { |k, v| condition_value(v, "#{path}.#{k}") }
|
|
161
|
+
when Numeric then wrong(path, "a number within the range of a double", value) if value.to_f.infinite?
|
|
162
|
+
end
|
|
163
|
+
value
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def flag(value, path)
|
|
167
|
+
f = object(value, path)
|
|
168
|
+
Flag.new(
|
|
169
|
+
key: string(f["key"], "#{path}.key"),
|
|
170
|
+
type: string(f["type"], "#{path}.type"),
|
|
171
|
+
automatic_exposure: bool(f["automatic_exposure"], "#{path}.automatic_exposure"),
|
|
172
|
+
variations: list(f["variations"], "#{path}.variations") { |v, p| variation(v, p) },
|
|
173
|
+
on: bool(f["on"], "#{path}.on"),
|
|
174
|
+
off_variation: string(f["off_variation"], "#{path}.off_variation"),
|
|
175
|
+
fallthrough: string(f["fallthrough"], "#{path}.fallthrough"),
|
|
176
|
+
prerequisites: list(f["prerequisites"], "#{path}.prerequisites") { |r, p| prerequisite(r, p) },
|
|
177
|
+
overrides: list(f["overrides"], "#{path}.overrides") { |o, p| override(o, p) },
|
|
178
|
+
rules: list(f["rules"], "#{path}.rules") { |r, p| rule(r, p) }
|
|
179
|
+
).freeze
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def variation(value, path)
|
|
183
|
+
v = object(value, path)
|
|
184
|
+
Variation.new(key: string(v["key"], "#{path}.key"), value: v["value"]).freeze
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def prerequisite(value, path)
|
|
188
|
+
r = object(value, path)
|
|
189
|
+
Prerequisite.new(flag: string(r["flag"], "#{path}.flag"),
|
|
190
|
+
variation: string(r["variation"], "#{path}.variation")).freeze
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def override(value, path)
|
|
194
|
+
o = object(value, path)
|
|
195
|
+
Override.new(unit_type: string(o["unit_type"], "#{path}.unit_type"),
|
|
196
|
+
unit_id: string(o["unit_id"], "#{path}.unit_id"),
|
|
197
|
+
variation: string(o["variation"], "#{path}.variation")).freeze
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def rule(value, path)
|
|
201
|
+
r = object(value, path)
|
|
202
|
+
Rule.new(id: string(r["id"], "#{path}.id"),
|
|
203
|
+
conditions: list(r["conditions"], "#{path}.conditions") { |c, p| condition(c, p) },
|
|
204
|
+
serve: serve(r["serve"], "#{path}.serve")).freeze
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def serve(value, path)
|
|
208
|
+
s = object(value, path)
|
|
209
|
+
strings = %w[kind variation unit_type salt experiment inclusion_salt variation_salt].to_h do |k|
|
|
210
|
+
[k.to_sym, string(s[k], "#{path}.#{k}")]
|
|
211
|
+
end
|
|
212
|
+
Serve.new(
|
|
213
|
+
percentage: int(s["percentage"], "#{path}.percentage"),
|
|
214
|
+
traffic_allocation: int(s["traffic_allocation"], "#{path}.traffic_allocation"),
|
|
215
|
+
weights: list(s["weights"], "#{path}.weights") { |w, p| weight(w, p) },
|
|
216
|
+
**strings
|
|
217
|
+
).freeze
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def weight(value, path)
|
|
221
|
+
w = object(value, path)
|
|
222
|
+
Weight.new(variation: string(w["variation"], "#{path}.variation"),
|
|
223
|
+
weight: int(w["weight"], "#{path}.weight")).freeze
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def object(value, path)
|
|
227
|
+
return {} if value.nil?
|
|
228
|
+
return value if value.is_a?(Hash)
|
|
229
|
+
|
|
230
|
+
wrong(path, "an object", value)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def list(value, path)
|
|
234
|
+
return [].freeze if value.nil?
|
|
235
|
+
|
|
236
|
+
wrong(path, "a list", value) unless value.is_a?(Array)
|
|
237
|
+
value.each_with_index.map { |e, i| yield e, "#{path}[#{i}]" }.freeze
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def string(value, path)
|
|
241
|
+
return "" if value.nil?
|
|
242
|
+
return value if value.is_a?(String)
|
|
243
|
+
|
|
244
|
+
wrong(path, "a string", value)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def bool(value, path)
|
|
248
|
+
return false if value.nil?
|
|
249
|
+
return value if [true, false].include?(value)
|
|
250
|
+
|
|
251
|
+
wrong(path, "a boolean", value)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def int(value, path)
|
|
255
|
+
return 0 if value.nil?
|
|
256
|
+
return value if value.is_a?(Integer) && INT64.cover?(value)
|
|
257
|
+
|
|
258
|
+
wrong(path, "an integer", value)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def wrong(path, want, value)
|
|
262
|
+
raise DecodeError, "decode ruleset: #{path} must be #{want}, got #{JSON.generate(value, allow_nan: true)}"
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
private_constant :Document
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flagnetic
|
|
4
|
+
module Eval
|
|
5
|
+
# Semver is a parsed semantic version. Build metadata is dropped because
|
|
6
|
+
# it never affects precedence.
|
|
7
|
+
class Semver
|
|
8
|
+
include Comparable
|
|
9
|
+
|
|
10
|
+
CORE = /\A[0-9]+(?:\.[0-9]+){0,2}\z/
|
|
11
|
+
IDENTIFIERS = /\A[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*\z/
|
|
12
|
+
NUMERIC = /\A[0-9]+\z/
|
|
13
|
+
private_constant :CORE, :IDENTIFIERS, :NUMERIC
|
|
14
|
+
|
|
15
|
+
# parse reads an optional lower-case "v", one to three dot-separated
|
|
16
|
+
# non-negative integers (missing components are 0, so "1.41" equals
|
|
17
|
+
# "1.41.0"), an optional "-" pre-release and an optional "+" build.
|
|
18
|
+
# Numeric components are unbounded and leading zeros are ignored.
|
|
19
|
+
# Pre-release and build identifiers are non-empty runs of
|
|
20
|
+
# [0-9A-Za-z-]. It returns nil for anything else.
|
|
21
|
+
def self.parse(str)
|
|
22
|
+
return nil unless str.is_a?(String) && str.valid_encoding?
|
|
23
|
+
|
|
24
|
+
s = str.delete_prefix("v")
|
|
25
|
+
s, build = s.split("+", 2)
|
|
26
|
+
return nil if build && !build.match?(IDENTIFIERS)
|
|
27
|
+
|
|
28
|
+
s, prerelease = s.to_s.split("-", 2)
|
|
29
|
+
return nil if prerelease && !prerelease.match?(IDENTIFIERS)
|
|
30
|
+
return nil unless s.to_s.match?(CORE)
|
|
31
|
+
|
|
32
|
+
core = s.split(".").map(&:to_i)
|
|
33
|
+
new(core.fill(0, core.size...3), prerelease ? prerelease.split(".") : [])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
attr_reader :core, :prerelease
|
|
37
|
+
|
|
38
|
+
def initialize(core, prerelease)
|
|
39
|
+
@core = core.freeze
|
|
40
|
+
@prerelease = prerelease.freeze
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# <=> orders versions by SemVer 2.0.0 section 11 precedence.
|
|
45
|
+
def <=>(other)
|
|
46
|
+
return nil unless other.is_a?(Semver)
|
|
47
|
+
|
|
48
|
+
c = core <=> other.core
|
|
49
|
+
return c unless c.zero?
|
|
50
|
+
return 0 if prerelease.empty? && other.prerelease.empty?
|
|
51
|
+
return 1 if prerelease.empty?
|
|
52
|
+
return -1 if other.prerelease.empty?
|
|
53
|
+
|
|
54
|
+
prerelease.zip(other.prerelease).each do |a, b|
|
|
55
|
+
break if b.nil?
|
|
56
|
+
|
|
57
|
+
c = compare_identifiers(a, b)
|
|
58
|
+
return c unless c.zero?
|
|
59
|
+
end
|
|
60
|
+
prerelease.size <=> other.prerelease.size
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# compare_identifiers orders pre-release identifiers: numeric ones
|
|
66
|
+
# numerically and below alphanumeric ones, alphanumeric ones in ASCII
|
|
67
|
+
# order.
|
|
68
|
+
def compare_identifiers(left, right)
|
|
69
|
+
ln = left.match?(NUMERIC)
|
|
70
|
+
rn = right.match?(NUMERIC)
|
|
71
|
+
return left.to_i <=> right.to_i if ln && rn
|
|
72
|
+
return -1 if ln
|
|
73
|
+
return 1 if rn
|
|
74
|
+
|
|
75
|
+
left.b <=> right.b
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
private_constant :Semver
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flagnetic
|
|
4
|
+
module Eval
|
|
5
|
+
# Context is the subject of an evaluation: unit IDs by unit type, and
|
|
6
|
+
# attributes. A context with no units is valid: attribute rules apply;
|
|
7
|
+
# rollouts, experiments and overrides are skipped. An empty unit ID
|
|
8
|
+
# counts as absent. Keys may be strings or symbols.
|
|
9
|
+
#
|
|
10
|
+
# Attribute values: String, true or false, a real Numeric, an Array of
|
|
11
|
+
# strings, and for dates an RFC 3339 string, integral Unix epoch
|
|
12
|
+
# milliseconds or a Time.
|
|
13
|
+
Context = Struct.new(:units, :attributes, keyword_init: true) do
|
|
14
|
+
def initialize(units: nil, attributes: nil)
|
|
15
|
+
super(units: (units || {}).transform_keys(&:to_s).freeze,
|
|
16
|
+
attributes: (attributes || {}).transform_keys(&:to_s).freeze)
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# unit returns the unit ID of a unit type, or nil when absent.
|
|
21
|
+
def unit(unit_type)
|
|
22
|
+
id = units[unit_type]
|
|
23
|
+
id.is_a?(String) && !id.empty? ? Values.utf8(id) : nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# coerce accepts a Context, a Hash with units and attributes, or nil.
|
|
27
|
+
def self.coerce(context)
|
|
28
|
+
case context
|
|
29
|
+
when Context then context
|
|
30
|
+
when nil then self::EMPTY
|
|
31
|
+
when Hash
|
|
32
|
+
c = context.transform_keys(&:to_s)
|
|
33
|
+
new(units: c["units"], attributes: c["attributes"])
|
|
34
|
+
else raise ArgumentError, "context must be a Flagnetic::Eval::Context or a Hash, got #{context.class}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
private_class_method :coerce
|
|
38
|
+
end
|
|
39
|
+
Context::EMPTY = Context.new
|
|
40
|
+
|
|
41
|
+
# REASONS are the evaluation reasons the evaluator produces. fallback
|
|
42
|
+
# and local_override are produced by SDKs, never by the evaluator.
|
|
43
|
+
REASONS = %i[off override rule rollout experiment fallthrough prerequisite_failed error].freeze
|
|
44
|
+
|
|
45
|
+
# ERROR_CODES say why a result has reason error.
|
|
46
|
+
ERROR_CODES = %i[flag_not_found prerequisite_cycle inconsistent_ruleset].freeze
|
|
47
|
+
|
|
48
|
+
# Construct is an operator, a condition kind or a serve kind of a
|
|
49
|
+
# ruleset that this evaluator does not know. kind is :operator,
|
|
50
|
+
# :condition_kind or :serve_kind.
|
|
51
|
+
Construct = Struct.new(:kind, :name) do
|
|
52
|
+
def as_json
|
|
53
|
+
{ "kind" => kind.to_s, "name" => name }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Result is the outcome of evaluating one flag for one context. Its
|
|
58
|
+
# as_json form is what the conformance fixtures compare, key for key.
|
|
59
|
+
#
|
|
60
|
+
# - type: the flag's type, and automatic_exposure its setting: with it
|
|
61
|
+
# an SDK emits an exposure after every evaluation whose result carries
|
|
62
|
+
# an experiment, with no second call. Both are the flag's rather than
|
|
63
|
+
# the evaluation's and are nil only when there is no flag to read them
|
|
64
|
+
# from, an error with flag_not_found.
|
|
65
|
+
# - value, variation: the served variation; nil for errors.
|
|
66
|
+
# - reason: one of REASONS.
|
|
67
|
+
# - rule_id: the rule that served, for reasons rule, rollout and
|
|
68
|
+
# experiment.
|
|
69
|
+
# - experiment: an ExperimentDetail with reason experiment, and with
|
|
70
|
+
# reason override when the override's shadow evaluation lands in a
|
|
71
|
+
# running experiment that includes the unit.
|
|
72
|
+
# - prerequisite: the flag-level prerequisite that failed, for reason
|
|
73
|
+
# prerequisite_failed.
|
|
74
|
+
# - bucket: the unit's bucket for reason rollout.
|
|
75
|
+
# - error_code: one of ERROR_CODES, for reason error.
|
|
76
|
+
# - override_influenced: an override decided the result, or any
|
|
77
|
+
# prerequisite sub-evaluation consulted on the way was
|
|
78
|
+
# override-influenced, whatever the final reason. Every exposure and
|
|
79
|
+
# event derived from the result carries it.
|
|
80
|
+
Result = Struct.new(:type, :automatic_exposure, :value, :variation, :reason, :rule_id, :experiment,
|
|
81
|
+
:prerequisite, :bucket, :error_code, :override_influenced, keyword_init: true) do
|
|
82
|
+
def initialize(override_influenced: false, **fields)
|
|
83
|
+
super
|
|
84
|
+
freeze
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# of_flag is the result stamped with the flag it is about: every step
|
|
88
|
+
# leaves type and automatic_exposure alone, so one place fills them in
|
|
89
|
+
# for every reason a known flag can carry.
|
|
90
|
+
def of_flag(flag)
|
|
91
|
+
Result.new(**to_h, type: flag.type, automatic_exposure: flag.automatic_exposure)
|
|
92
|
+
end
|
|
93
|
+
private :of_flag
|
|
94
|
+
|
|
95
|
+
# as_json is the result's JSON form, keyed as the reference
|
|
96
|
+
# evaluator encodes it: the flag's type and automatic exposure unless
|
|
97
|
+
# there is no flag, the served value unless the reason is error, and
|
|
98
|
+
# every other field unless it is unset or an empty string.
|
|
99
|
+
def as_json
|
|
100
|
+
out = type.nil? ? {} : { "type" => type }
|
|
101
|
+
out["value"] = value unless reason == :error
|
|
102
|
+
{
|
|
103
|
+
"variation" => variation, "reason" => reason.to_s, "rule_id" => rule_id
|
|
104
|
+
}.each { |k, v| out[k] = v unless v.nil? || v == "" }
|
|
105
|
+
out["automatic_exposure"] = automatic_exposure unless type.nil?
|
|
106
|
+
{
|
|
107
|
+
"experiment" => experiment&.as_json, "prerequisite" => prerequisite,
|
|
108
|
+
"bucket" => bucket, "error_code" => error_code&.to_s
|
|
109
|
+
}.each { |k, v| out[k] = v unless v.nil? || v == "" }
|
|
110
|
+
out["override_influenced"] = override_influenced
|
|
111
|
+
out
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# ExperimentDetail is everything an SDK needs to report an exposure: the
|
|
116
|
+
# one shape of experiment detail shared by local and remote evaluation.
|
|
117
|
+
# variation is the served variation: the assignment, or the override's
|
|
118
|
+
# variation when an override decided the result. override equals the
|
|
119
|
+
# result's override_influenced; exposures carrying it are excluded from
|
|
120
|
+
# analysis.
|
|
121
|
+
ExperimentDetail = Struct.new(:key, :variation, :unit_type, :unit_id, :inclusion_salt, :variation_salt,
|
|
122
|
+
:inclusion_bucket, :variation_bucket, :override, keyword_init: true) do
|
|
123
|
+
def initialize(**fields)
|
|
124
|
+
super
|
|
125
|
+
freeze
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def as_json
|
|
129
|
+
to_h.transform_keys(&:to_s)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The ruleset document, as decoded. Every struct is frozen.
|
|
134
|
+
Environment = Struct.new(:key, :protected, keyword_init: true)
|
|
135
|
+
Attribute = Struct.new(:key, :type, keyword_init: true)
|
|
136
|
+
Segment = Struct.new(:key, :private, :conditions, keyword_init: true)
|
|
137
|
+
Condition = Struct.new(:kind, :attribute, :op, :value, :segment, :flag, :variation, keyword_init: true)
|
|
138
|
+
Flag = Struct.new(:key, :type, :automatic_exposure, :variations, :on, :off_variation,
|
|
139
|
+
:fallthrough, :prerequisites, :overrides, :rules, keyword_init: true)
|
|
140
|
+
Variation = Struct.new(:key, :value, keyword_init: true)
|
|
141
|
+
Prerequisite = Struct.new(:flag, :variation, keyword_init: true)
|
|
142
|
+
Override = Struct.new(:unit_type, :unit_id, :variation, keyword_init: true)
|
|
143
|
+
Rule = Struct.new(:id, :conditions, :serve, keyword_init: true)
|
|
144
|
+
Serve = Struct.new(:kind, :variation, :unit_type, :percentage, :salt, :experiment, :inclusion_salt,
|
|
145
|
+
:variation_salt, :traffic_allocation, :weights, keyword_init: true)
|
|
146
|
+
Weight = Struct.new(:variation, :weight, keyword_init: true)
|
|
147
|
+
end
|
|
148
|
+
end
|