featbit-server-sdk 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/CHANGELOG.md +11 -0
- data/LICENSE +201 -0
- data/README.md +252 -0
- data/lib/featbit/client.rb +282 -0
- data/lib/featbit/data_store.rb +188 -0
- data/lib/featbit/evaluation_detail.rb +12 -0
- data/lib/featbit/evaluator.rb +230 -0
- data/lib/featbit/event_processor.rb +225 -0
- data/lib/featbit/options.rb +107 -0
- data/lib/featbit/status.rb +103 -0
- data/lib/featbit/user.rb +66 -0
- data/lib/featbit/version.rb +5 -0
- data/lib/featbit/web_socket_data_synchronizer.rb +293 -0
- data/lib/featbit.rb +15 -0
- metadata +88 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module FeatBit
|
|
6
|
+
class Client
|
|
7
|
+
attr_reader :options, :status_provider, :event_processor, :data_store
|
|
8
|
+
|
|
9
|
+
def initialize(options = Options.new)
|
|
10
|
+
@options = options.is_a?(Options) ? options : Options.new(offline: true)
|
|
11
|
+
@data_store = @options.data_store || InMemoryDataStore.new
|
|
12
|
+
initial_status = @options.offline ? Status::OFFLINE : Status::STARTING
|
|
13
|
+
@status_provider = StatusProvider.new(initial_status, logger: @options.logger)
|
|
14
|
+
@listeners = {}
|
|
15
|
+
@listener_id = 0
|
|
16
|
+
@listener_mutex = Mutex.new
|
|
17
|
+
@lifecycle_mutex = Mutex.new
|
|
18
|
+
@closed = false
|
|
19
|
+
@close_result = nil
|
|
20
|
+
|
|
21
|
+
load_bootstrap(@options.bootstrap) if @options.bootstrap
|
|
22
|
+
@event_processor = build_event_processor
|
|
23
|
+
@evaluator = Evaluator.new(
|
|
24
|
+
flag_getter: ->(key) { @data_store.flag(key) },
|
|
25
|
+
segment_getter: ->(id) { @data_store.segment(id) },
|
|
26
|
+
logger: @options.logger
|
|
27
|
+
)
|
|
28
|
+
start_synchronizer unless @options.offline
|
|
29
|
+
@status_provider.update(Status::READY) if @options.offline && @data_store.initialized?
|
|
30
|
+
@status_provider.wait_until_ready(@options.start_wait) if !@options.offline && @options.start_wait.positive?
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
safe_log(:error, "FeatBit client initialization failed: #{e.message}")
|
|
33
|
+
@status_provider ||= StatusProvider.new(Status::FAILED)
|
|
34
|
+
@status_provider.update(Status::FAILED, message: e.message)
|
|
35
|
+
@event_processor ||= NullEventProcessor.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialized?
|
|
39
|
+
@data_store.initialized?
|
|
40
|
+
rescue StandardError
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def variation(flag_key, user, default_value)
|
|
45
|
+
variation_detail(flag_key, user, default_value).value
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
safe_log(:error, "FeatBit variation failed: #{e.message}")
|
|
48
|
+
default_value
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def variation_detail(flag_key, user, default_value)
|
|
52
|
+
return safe_detail(flag_key, default_value, :client_not_ready) if @closed || !evaluable?
|
|
53
|
+
|
|
54
|
+
normalized_user = normalize_user(user)
|
|
55
|
+
detail = @evaluator.evaluate(flag_key, normalized_user, default_value)
|
|
56
|
+
enqueue_evaluation(detail, normalized_user) if detail.success?
|
|
57
|
+
detail
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
safe_log(:error, "FeatBit variation detail failed: #{e.message}")
|
|
60
|
+
safe_detail(flag_key, default_value, :error, e.message)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def bool_variation(flag_key, user, default_value = false)
|
|
64
|
+
typed_variation(flag_key, user, default_value, [TrueClass, FalseClass])
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def string_variation(flag_key, user, default_value = "")
|
|
68
|
+
typed_variation(flag_key, user, default_value, [String])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def number_variation(flag_key, user, default_value = 0)
|
|
72
|
+
typed_variation(flag_key, user, default_value, [Numeric])
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def json_variation(flag_key, user, default_value = {})
|
|
76
|
+
typed_variation(flag_key, user, default_value, [Hash, Array])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def track(user, event_name, numeric_value = 1.0)
|
|
80
|
+
normalized_user = normalize_user(user)
|
|
81
|
+
return false unless normalized_user.valid? && !event_name.to_s.empty?
|
|
82
|
+
|
|
83
|
+
@event_processor.enqueue(
|
|
84
|
+
user: normalized_user.to_h,
|
|
85
|
+
metrics: [{
|
|
86
|
+
eventName: event_name.to_s,
|
|
87
|
+
numericValue: numeric_value.to_f,
|
|
88
|
+
route: "index/metric",
|
|
89
|
+
type: "CustomEvent",
|
|
90
|
+
appType: "ruby-server-side",
|
|
91
|
+
timestamp: timestamp_ms
|
|
92
|
+
}]
|
|
93
|
+
)
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
safe_log(:warn, "FeatBit track failed: #{e.message}")
|
|
96
|
+
false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def flush
|
|
100
|
+
@event_processor.flush
|
|
101
|
+
rescue StandardError
|
|
102
|
+
false
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def add_flag_change_listener(callable = nil, &block)
|
|
106
|
+
listener = callable || block
|
|
107
|
+
return nil unless listener.respond_to?(:call)
|
|
108
|
+
|
|
109
|
+
@listener_mutex.synchronize do
|
|
110
|
+
@listener_id += 1
|
|
111
|
+
@listeners[@listener_id] = listener
|
|
112
|
+
@listener_id
|
|
113
|
+
end
|
|
114
|
+
rescue StandardError
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def remove_flag_change_listener(id)
|
|
119
|
+
@listener_mutex.synchronize { !@listeners.delete(id).nil? }
|
|
120
|
+
rescue StandardError
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def close
|
|
125
|
+
@lifecycle_mutex.synchronize do
|
|
126
|
+
return @close_result unless @close_result.nil?
|
|
127
|
+
return true if @closed
|
|
128
|
+
|
|
129
|
+
@closed = true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
results = [safe_close(@synchronizer), safe_close(@event_processor)]
|
|
133
|
+
results << safe_status_update(Status::CLOSED)
|
|
134
|
+
@lifecycle_mutex.synchronize { @close_result = results.all? }
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
safe_log(:warn, "FeatBit client close failed: #{e.message}")
|
|
137
|
+
false
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
alias stop close
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def evaluable?
|
|
145
|
+
@data_store.initialized? && [Status::READY, Status::OFFLINE, Status::INTERRUPTED].include?(@status_provider.status)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def load_bootstrap(bootstrap)
|
|
149
|
+
success = @data_store.init(bootstrap)
|
|
150
|
+
@status_provider.update(success ? Status::READY : Status::FAILED, message: success ? nil : "invalid bootstrap")
|
|
151
|
+
rescue StandardError => e
|
|
152
|
+
@status_provider.update(Status::FAILED, message: e.message)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def build_event_processor
|
|
156
|
+
return NullEventProcessor.new if @options.offline || @options.disable_events || !@options.valid?
|
|
157
|
+
return @options.event_processor_factory.call(@options) if @options.event_processor_factory
|
|
158
|
+
|
|
159
|
+
EventProcessor.new(@options)
|
|
160
|
+
rescue StandardError => e
|
|
161
|
+
safe_log(:warn, "FeatBit event processor unavailable: #{e.message}")
|
|
162
|
+
NullEventProcessor.new
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def start_synchronizer
|
|
166
|
+
unless @options.valid?
|
|
167
|
+
@status_provider.update(Status::FAILED, message: "invalid options")
|
|
168
|
+
return
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
@synchronizer = if @options.synchronizer_factory
|
|
172
|
+
@options.synchronizer_factory.call(@options, @data_store, @status_provider, method(:broadcast_flag_change))
|
|
173
|
+
else
|
|
174
|
+
WebSocketDataSynchronizer.new(
|
|
175
|
+
options: @options,
|
|
176
|
+
data_store: @data_store,
|
|
177
|
+
status_provider: @status_provider,
|
|
178
|
+
on_flags_changed: method(:broadcast_flag_change)
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
@synchronizer.start
|
|
182
|
+
rescue StandardError => e
|
|
183
|
+
@status_provider.update(Status::FAILED, message: e.message)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def typed_variation(flag_key, user, default_value, expected_types)
|
|
187
|
+
value = variation(flag_key, user, default_value)
|
|
188
|
+
expected_types.any? { |type| value.is_a?(type) } ? value : default_value
|
|
189
|
+
rescue StandardError
|
|
190
|
+
default_value
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def normalize_user(user)
|
|
194
|
+
return user if user.is_a?(User)
|
|
195
|
+
return User.new(user) if user.is_a?(String)
|
|
196
|
+
|
|
197
|
+
if user.is_a?(Hash)
|
|
198
|
+
key = user["key"] || user[:key] || user["keyId"] || user[:keyId] || user["targeting_key"] || user[:targeting_key]
|
|
199
|
+
name = user["name"] || user[:name]
|
|
200
|
+
custom = user.reject { |candidate, _| %w[key keyId targeting_key name].include?(candidate.to_s) }
|
|
201
|
+
return User.new(key, name: name, custom: custom)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
User.new("")
|
|
205
|
+
rescue StandardError
|
|
206
|
+
User.new("")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def enqueue_evaluation(detail, user)
|
|
210
|
+
@event_processor.enqueue(
|
|
211
|
+
user: user.to_h,
|
|
212
|
+
variations: [{
|
|
213
|
+
featureFlagKey: detail.flag_key,
|
|
214
|
+
sendToExperiment: detail.send_to_experiment == true,
|
|
215
|
+
timestamp: timestamp_ms,
|
|
216
|
+
variation: { id: detail.variation_id, value: serialize_variation_value(detail.value), reason: detail.reason }
|
|
217
|
+
}]
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def serialize_variation_value(value)
|
|
222
|
+
case value
|
|
223
|
+
when String then value
|
|
224
|
+
when TrueClass then "true"
|
|
225
|
+
when FalseClass then "false"
|
|
226
|
+
when Hash, Array then JSON.generate(value)
|
|
227
|
+
when nil then ""
|
|
228
|
+
else value.to_s
|
|
229
|
+
end
|
|
230
|
+
rescue StandardError
|
|
231
|
+
""
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def broadcast_flag_change(flag_key)
|
|
235
|
+
listeners = @listener_mutex.synchronize { @listeners.values.dup }
|
|
236
|
+
listeners.each do |listener|
|
|
237
|
+
listener.call(flag_key)
|
|
238
|
+
rescue StandardError => e
|
|
239
|
+
safe_log(:warn, "FeatBit flag listener failed: #{e.message}")
|
|
240
|
+
end
|
|
241
|
+
true
|
|
242
|
+
rescue StandardError
|
|
243
|
+
false
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def safe_detail(flag_key, default_value, reason, message = nil)
|
|
247
|
+
EvaluationDetail.new(
|
|
248
|
+
value: default_value,
|
|
249
|
+
reason: Evaluator::REASONS.fetch(reason),
|
|
250
|
+
flag_key: flag_key,
|
|
251
|
+
error_kind: reason,
|
|
252
|
+
error_message: message
|
|
253
|
+
)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def safe_log(level, message)
|
|
257
|
+
@options&.logger&.public_send(level, message)
|
|
258
|
+
rescue StandardError
|
|
259
|
+
nil
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def safe_close(component)
|
|
263
|
+
return true unless component
|
|
264
|
+
|
|
265
|
+
component.close != false
|
|
266
|
+
rescue StandardError => e
|
|
267
|
+
safe_log(:warn, "FeatBit component close failed: #{e.message}")
|
|
268
|
+
false
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def safe_status_update(status)
|
|
272
|
+
@status_provider.update(status) != false
|
|
273
|
+
rescue StandardError => e
|
|
274
|
+
safe_log(:warn, "FeatBit status update failed: #{e.message}")
|
|
275
|
+
false
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def timestamp_ms
|
|
279
|
+
(Time.now.to_f * 1000).round
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module FeatBit
|
|
7
|
+
class InMemoryDataStore
|
|
8
|
+
def initialize
|
|
9
|
+
@flags = {}.freeze
|
|
10
|
+
@segments = {}.freeze
|
|
11
|
+
@flag_versions = {}.freeze
|
|
12
|
+
@segment_versions = {}.freeze
|
|
13
|
+
@version = 0
|
|
14
|
+
@initialized = false
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialized?
|
|
19
|
+
@mutex.synchronize { @initialized }
|
|
20
|
+
rescue StandardError
|
|
21
|
+
false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def version
|
|
25
|
+
@mutex.synchronize { @version }
|
|
26
|
+
rescue StandardError
|
|
27
|
+
0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def flag(key)
|
|
31
|
+
item = @mutex.synchronize { @flags[key.to_s] }
|
|
32
|
+
item && !fetch(item, "isArchived", false) ? deep_dup(item) : nil
|
|
33
|
+
rescue StandardError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def segment(id)
|
|
38
|
+
item = @mutex.synchronize { @segments[id.to_s] }
|
|
39
|
+
item && !fetch(item, "isArchived", false) ? deep_dup(item) : nil
|
|
40
|
+
rescue StandardError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def all_flags
|
|
45
|
+
snapshot = @mutex.synchronize { @flags }
|
|
46
|
+
snapshot.each_with_object({}) do |(key, value), result|
|
|
47
|
+
result[key] = deep_dup(value) unless fetch(value, "isArchived", false)
|
|
48
|
+
end
|
|
49
|
+
rescue StandardError
|
|
50
|
+
{}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def init(payload, version: nil)
|
|
54
|
+
data = normalize_payload(payload)
|
|
55
|
+
flags = Array(fetch(data, "featureFlags", [])).to_h do |flag|
|
|
56
|
+
[fetch(flag, "key").to_s, normalize_flag(flag)]
|
|
57
|
+
end
|
|
58
|
+
segments = Array(fetch(data, "segments", [])).to_h do |segment|
|
|
59
|
+
[fetch(segment, "id").to_s, normalize_hash(segment)]
|
|
60
|
+
end
|
|
61
|
+
incoming_version = version || derive_version(flags.values + segments.values)
|
|
62
|
+
incoming_version = 1 if incoming_version.to_i <= 0
|
|
63
|
+
flag_versions = entity_versions(flags, version)
|
|
64
|
+
segment_versions = entity_versions(segments, version)
|
|
65
|
+
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
return false if @initialized && incoming_version.to_i <= @version
|
|
68
|
+
|
|
69
|
+
@flags = deep_freeze(flags)
|
|
70
|
+
@segments = deep_freeze(segments)
|
|
71
|
+
@flag_versions = deep_freeze(flag_versions)
|
|
72
|
+
@segment_versions = deep_freeze(segment_versions)
|
|
73
|
+
@version = incoming_version.to_i
|
|
74
|
+
@initialized = true
|
|
75
|
+
end
|
|
76
|
+
true
|
|
77
|
+
rescue StandardError
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def upsert(kind, item, version: nil)
|
|
82
|
+
normalized = normalize_hash(item)
|
|
83
|
+
key = kind.to_sym == :flags ? fetch(normalized, "key") : fetch(normalized, "id")
|
|
84
|
+
return false if key.to_s.empty?
|
|
85
|
+
|
|
86
|
+
incoming_version = version || item_version(normalized)
|
|
87
|
+
incoming_version = @mutex.synchronize { @version + 1 } if incoming_version.to_i <= 0
|
|
88
|
+
@mutex.synchronize do
|
|
89
|
+
if kind.to_sym == :flags
|
|
90
|
+
return false if incoming_version.to_i <= @flag_versions.fetch(key.to_s, 0)
|
|
91
|
+
|
|
92
|
+
copy = @flags.dup
|
|
93
|
+
version_copy = @flag_versions.dup
|
|
94
|
+
copy[key.to_s] = normalize_flag(normalized)
|
|
95
|
+
version_copy[key.to_s] = incoming_version.to_i
|
|
96
|
+
@flags = deep_freeze(copy)
|
|
97
|
+
@flag_versions = deep_freeze(version_copy)
|
|
98
|
+
else
|
|
99
|
+
return false if incoming_version.to_i <= @segment_versions.fetch(key.to_s, 0)
|
|
100
|
+
|
|
101
|
+
copy = @segments.dup
|
|
102
|
+
version_copy = @segment_versions.dup
|
|
103
|
+
copy[key.to_s] = normalized
|
|
104
|
+
version_copy[key.to_s] = incoming_version.to_i
|
|
105
|
+
@segments = deep_freeze(copy)
|
|
106
|
+
@segment_versions = deep_freeze(version_copy)
|
|
107
|
+
end
|
|
108
|
+
@version = [@version, incoming_version.to_i].max
|
|
109
|
+
@initialized = true
|
|
110
|
+
end
|
|
111
|
+
true
|
|
112
|
+
rescue StandardError
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def normalize_payload(payload)
|
|
119
|
+
parsed = payload.is_a?(String) ? JSON.parse(payload) : payload
|
|
120
|
+
parsed = fetch(parsed, "data", parsed)
|
|
121
|
+
normalize_hash(parsed || {})
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def normalize_flag(flag)
|
|
125
|
+
normalized = normalize_hash(flag)
|
|
126
|
+
variations = Array(fetch(normalized, "variations", []))
|
|
127
|
+
normalized["variationMap"] = variations.to_h do |variation|
|
|
128
|
+
[fetch(variation, "id").to_s, fetch(variation, "value")]
|
|
129
|
+
end
|
|
130
|
+
normalized
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def normalize_hash(value)
|
|
134
|
+
case value
|
|
135
|
+
when Hash
|
|
136
|
+
value.each_with_object({}) { |(key, item), result| result[key.to_s] = normalize_hash(item) }
|
|
137
|
+
when Array
|
|
138
|
+
value.map { |item| normalize_hash(item) }
|
|
139
|
+
else
|
|
140
|
+
value
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def derive_version(items)
|
|
145
|
+
items.map { |item| item_version(item) }.max.to_i
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def entity_versions(items, snapshot_version)
|
|
149
|
+
items.transform_values do |item|
|
|
150
|
+
next snapshot_version.to_i if snapshot_version
|
|
151
|
+
|
|
152
|
+
version = item_version(item)
|
|
153
|
+
version.positive? ? version : 1
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def item_version(item)
|
|
158
|
+
explicit = fetch(item, "timestamp")
|
|
159
|
+
return explicit.to_i if explicit
|
|
160
|
+
|
|
161
|
+
updated = fetch(item, "updatedAt")
|
|
162
|
+
updated ? (Time.parse(updated.to_s).to_f * 1000).to_i : 0
|
|
163
|
+
rescue StandardError
|
|
164
|
+
0
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def fetch(hash, key, default = nil)
|
|
168
|
+
hash.is_a?(Hash) ? hash.fetch(key.to_s, hash.fetch(key.to_sym, default)) : default
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def deep_dup(value)
|
|
172
|
+
Marshal.load(Marshal.dump(value))
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def deep_freeze(value)
|
|
176
|
+
case value
|
|
177
|
+
when Hash
|
|
178
|
+
value.each do |key, item|
|
|
179
|
+
deep_freeze(key)
|
|
180
|
+
deep_freeze(item)
|
|
181
|
+
end
|
|
182
|
+
when Array
|
|
183
|
+
value.each { |item| deep_freeze(item) }
|
|
184
|
+
end
|
|
185
|
+
value.freeze
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FeatBit
|
|
4
|
+
EvaluationDetail = Struct.new(
|
|
5
|
+
:value, :reason, :variation_id, :flag_key, :flag_name, :send_to_experiment, :error_kind, :error_message,
|
|
6
|
+
keyword_init: true
|
|
7
|
+
) do
|
|
8
|
+
def success?
|
|
9
|
+
error_kind.nil?
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|