txlemetry-ruby 4.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 +7 -0
- data/LICENSE +48 -0
- data/NOTICE +5 -0
- data/bin/txlemetry +110 -0
- data/lib/txlemetry/backoff_policy.rb +49 -0
- data/lib/txlemetry/client.rb +1266 -0
- data/lib/txlemetry/defaults.rb +50 -0
- data/lib/txlemetry/exception_capture.rb +246 -0
- data/lib/txlemetry/feature_flag.rb +85 -0
- data/lib/txlemetry/feature_flag_error.rb +37 -0
- data/lib/txlemetry/feature_flag_evaluations.rb +237 -0
- data/lib/txlemetry/feature_flag_result.rb +81 -0
- data/lib/txlemetry/feature_flags.rb +1516 -0
- data/lib/txlemetry/field_parser.rb +219 -0
- data/lib/txlemetry/flag_definition_cache.rb +88 -0
- data/lib/txlemetry/internal/context.rb +119 -0
- data/lib/txlemetry/logging.rb +77 -0
- data/lib/txlemetry/mcp/README.md +53 -0
- data/lib/txlemetry/mcp/analytics.rb +116 -0
- data/lib/txlemetry/mcp/client.rb +257 -0
- data/lib/txlemetry/mcp/constants.rb +111 -0
- data/lib/txlemetry/mcp/conversation_id.rb +89 -0
- data/lib/txlemetry/mcp/event_builder.rb +168 -0
- data/lib/txlemetry/mcp/exceptions.rb +112 -0
- data/lib/txlemetry/mcp/identity.rb +194 -0
- data/lib/txlemetry/mcp/ids.rb +56 -0
- data/lib/txlemetry/mcp/instrumentation.rb +736 -0
- data/lib/txlemetry/mcp/intent.rb +101 -0
- data/lib/txlemetry/mcp/log.rb +37 -0
- data/lib/txlemetry/mcp/options.rb +128 -0
- data/lib/txlemetry/mcp/rack_middleware.rb +112 -0
- data/lib/txlemetry/mcp/request_scope.rb +72 -0
- data/lib/txlemetry/mcp/sanitization.rb +504 -0
- data/lib/txlemetry/mcp/schema_mutation.rb +179 -0
- data/lib/txlemetry/mcp/server_extension.rb +54 -0
- data/lib/txlemetry/mcp/session.rb +71 -0
- data/lib/txlemetry/mcp/session_token.rb +106 -0
- data/lib/txlemetry/mcp/sink.rb +98 -0
- data/lib/txlemetry/mcp/tools.rb +91 -0
- data/lib/txlemetry/mcp/tracking_data.rb +89 -0
- data/lib/txlemetry/mcp/truncation.rb +326 -0
- data/lib/txlemetry/mcp.rb +224 -0
- data/lib/txlemetry/message_batch.rb +80 -0
- data/lib/txlemetry/noop_worker.rb +39 -0
- data/lib/txlemetry/response.rb +17 -0
- data/lib/txlemetry/send_feature_flags_options.rb +49 -0
- data/lib/txlemetry/send_worker.rb +218 -0
- data/lib/txlemetry/transport.rb +216 -0
- data/lib/txlemetry/utils.rb +183 -0
- data/lib/txlemetry/version.rb +5 -0
- data/lib/txlemetry.rb +16 -0
- metadata +105 -0
|
@@ -0,0 +1,1516 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'txlemetry/version'
|
|
7
|
+
require 'txlemetry/logging'
|
|
8
|
+
require 'txlemetry/defaults'
|
|
9
|
+
require 'txlemetry/backoff_policy'
|
|
10
|
+
require 'txlemetry/feature_flag'
|
|
11
|
+
require 'txlemetry/flag_definition_cache'
|
|
12
|
+
require 'digest'
|
|
13
|
+
|
|
14
|
+
module Txlemetry
|
|
15
|
+
class InconclusiveMatchError < StandardError
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# RequiresServerEvaluation is raised when feature flag evaluation requires
|
|
19
|
+
# server-side data that is not available locally (e.g., static cohorts,
|
|
20
|
+
# experience continuity). This error should propagate immediately to trigger
|
|
21
|
+
# API fallback, unlike InconclusiveMatchError which allows trying other conditions.
|
|
22
|
+
class RequiresServerEvaluation < StandardError
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Polls and evaluates feature flag definitions for {Txlemetry::Client}.
|
|
26
|
+
#
|
|
27
|
+
# @api private
|
|
28
|
+
class FeatureFlagsPoller
|
|
29
|
+
include Txlemetry::Logging
|
|
30
|
+
include Txlemetry::Utils
|
|
31
|
+
|
|
32
|
+
# @param polling_interval [Integer, nil] Seconds between local feature flag definition polls.
|
|
33
|
+
# Defaults to {Defaults::FeatureFlags::POLLING_INTERVAL_SECONDS}.
|
|
34
|
+
# @param secret_key [String, nil] Credential used to fetch local evaluation definitions. Accepts either a
|
|
35
|
+
# Personal API Key (`phx_...`) or a Project Secret API Key (`phs_...`).
|
|
36
|
+
# @param project_api_key [String] Project API key.
|
|
37
|
+
# @param host [String] Txlemetry API host URL.
|
|
38
|
+
# @param feature_flag_request_timeout_seconds [Integer] Timeout for feature flag requests.
|
|
39
|
+
# @param on_error [Proc, nil] Callback invoked as `on_error.call(status, error)`.
|
|
40
|
+
# @param flag_definition_cache_provider [Object, nil] Optional {FlagDefinitionCacheProvider} implementation.
|
|
41
|
+
# @param feature_flag_request_max_retries [Integer, nil] Retries after a transient network error or retryable
|
|
42
|
+
# HTTP response status on a flag request. Defaults to {Defaults::FeatureFlags::FLAG_REQUEST_MAX_RETRIES}.
|
|
43
|
+
# Set to 0 to disable retrying.
|
|
44
|
+
# @param async_load [Boolean] When true, flag definitions are fetched only on the poller thread: an
|
|
45
|
+
# immediate first tick at construction, then the regular polling cadence, which keeps retrying until a
|
|
46
|
+
# load succeeds.
|
|
47
|
+
# @param user_agent [String] User-Agent header sent with feature flag requests.
|
|
48
|
+
# @param flag_definitions_update_mutex [Mutex] Internal lock shared with flag-called deduplication.
|
|
49
|
+
# @param on_flag_definitions_updated [Proc, nil] Internal callback after definitions are applied or discarded,
|
|
50
|
+
# called while holding flag_definitions_update_mutex.
|
|
51
|
+
def initialize(
|
|
52
|
+
polling_interval,
|
|
53
|
+
secret_key,
|
|
54
|
+
project_api_key,
|
|
55
|
+
host,
|
|
56
|
+
feature_flag_request_timeout_seconds,
|
|
57
|
+
on_error = nil,
|
|
58
|
+
flag_definition_cache_provider: nil,
|
|
59
|
+
feature_flag_request_max_retries: nil,
|
|
60
|
+
async_load: false,
|
|
61
|
+
user_agent: "txlemetry-ruby/#{Txlemetry::VERSION}",
|
|
62
|
+
on_flag_definitions_updated: nil,
|
|
63
|
+
flag_definitions_update_mutex: Mutex.new
|
|
64
|
+
)
|
|
65
|
+
@polling_interval = polling_interval || Defaults::FeatureFlags::POLLING_INTERVAL_SECONDS
|
|
66
|
+
@secret_key = secret_key
|
|
67
|
+
@project_api_key = project_api_key
|
|
68
|
+
@host = host
|
|
69
|
+
@feature_flags = Concurrent::Array.new
|
|
70
|
+
@group_type_mapping = Concurrent::Hash.new
|
|
71
|
+
@cohorts = Concurrent::Hash.new
|
|
72
|
+
@loaded_flags_successfully_once = Concurrent::AtomicBoolean.new
|
|
73
|
+
@feature_flags_by_key = nil
|
|
74
|
+
@feature_flag_request_timeout_seconds = feature_flag_request_timeout_seconds
|
|
75
|
+
@feature_flag_request_max_retries =
|
|
76
|
+
feature_flag_request_max_retries || Defaults::FeatureFlags::FLAG_REQUEST_MAX_RETRIES
|
|
77
|
+
@on_error = on_error || proc { |status, error| }
|
|
78
|
+
@quota_limited = Concurrent::AtomicBoolean.new(false)
|
|
79
|
+
@flags_etag = Concurrent::AtomicReference.new(nil)
|
|
80
|
+
@flag_definitions_loaded_at = Concurrent::AtomicReference.new(nil)
|
|
81
|
+
@async_load = async_load
|
|
82
|
+
@user_agent = user_agent
|
|
83
|
+
@on_flag_definitions_updated = on_flag_definitions_updated
|
|
84
|
+
@flag_definitions_update_mutex = flag_definitions_update_mutex
|
|
85
|
+
# Server-controlled gate for minimal `$feature_flag_called` events, read
|
|
86
|
+
# from the top-level `minimal_flag_called_events` key of the local
|
|
87
|
+
# evaluation definitions payload. false when the server does not send it.
|
|
88
|
+
@minimal_flag_called_events = false
|
|
89
|
+
@definition_snapshot = Concurrent::AtomicReference.new({
|
|
90
|
+
flags: @feature_flags, flags_by_key: @feature_flags_by_key,
|
|
91
|
+
group_type_mapping: @group_type_mapping, cohorts: @cohorts,
|
|
92
|
+
minimal_flag_called_events: @minimal_flag_called_events, property_matching_version: 1
|
|
93
|
+
}.freeze)
|
|
94
|
+
@flag_definition_cache_provider = flag_definition_cache_provider
|
|
95
|
+
FlagDefinitionCacheProvider.validate!(@flag_definition_cache_provider) if @flag_definition_cache_provider
|
|
96
|
+
|
|
97
|
+
@task =
|
|
98
|
+
Concurrent::TimerTask.new(
|
|
99
|
+
execution_interval: @polling_interval,
|
|
100
|
+
run_now: @async_load
|
|
101
|
+
) { _load_feature_flags }
|
|
102
|
+
|
|
103
|
+
# If no secret_key, disable local evaluation & thus polling for definitions
|
|
104
|
+
if @secret_key.nil?
|
|
105
|
+
logger.info 'No secret_key provided, disabling local evaluation'
|
|
106
|
+
@loaded_flags_successfully_once.make_true
|
|
107
|
+
else
|
|
108
|
+
# load once synchronously before timer, unless @async_load
|
|
109
|
+
load_feature_flags unless @async_load
|
|
110
|
+
@task.execute
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def load_feature_flags(force_reload = false)
|
|
115
|
+
return if (@loaded_flags_successfully_once.true? || @async_load) && !force_reload
|
|
116
|
+
|
|
117
|
+
_load_feature_flags
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Whether flag definitions are currently loaded. False until the first
|
|
121
|
+
# successful load, and false again if a quota-limited (402) response
|
|
122
|
+
# discards them.
|
|
123
|
+
def definitions_loaded?
|
|
124
|
+
!@flag_definitions_loaded_at.value.nil?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Epoch milliseconds of the last successful definitions load, or nil.
|
|
128
|
+
def flag_definitions_loaded_at
|
|
129
|
+
@flag_definitions_loaded_at.value
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
attr_reader :feature_flags_by_key, :minimal_flag_called_events
|
|
133
|
+
|
|
134
|
+
def get_feature_variants(
|
|
135
|
+
distinct_id,
|
|
136
|
+
groups = {},
|
|
137
|
+
person_properties = {},
|
|
138
|
+
group_properties = {},
|
|
139
|
+
only_evaluate_locally = false,
|
|
140
|
+
raise_on_error = false
|
|
141
|
+
)
|
|
142
|
+
# TODO: Convert to options hash for easier argument passing
|
|
143
|
+
flags_data = get_all_flags_and_payloads(
|
|
144
|
+
distinct_id,
|
|
145
|
+
groups,
|
|
146
|
+
person_properties,
|
|
147
|
+
group_properties,
|
|
148
|
+
only_evaluate_locally,
|
|
149
|
+
raise_on_error
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if flags_data.key?(:featureFlags)
|
|
153
|
+
stringify_keys(flags_data[:featureFlags] || {})
|
|
154
|
+
else
|
|
155
|
+
logger.debug "Missing feature flags key: #{flags_data.to_json}"
|
|
156
|
+
{}
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def get_feature_payloads(
|
|
161
|
+
distinct_id,
|
|
162
|
+
groups = {},
|
|
163
|
+
person_properties = {},
|
|
164
|
+
group_properties = {},
|
|
165
|
+
_only_evaluate_locally = false
|
|
166
|
+
)
|
|
167
|
+
flags_data = get_all_flags_and_payloads(
|
|
168
|
+
distinct_id,
|
|
169
|
+
groups,
|
|
170
|
+
person_properties,
|
|
171
|
+
group_properties
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if flags_data.key?(:featureFlagPayloads)
|
|
175
|
+
stringify_keys(flags_data[:featureFlagPayloads] || {})
|
|
176
|
+
else
|
|
177
|
+
logger.debug "Missing feature flag payloads key: #{flags_data.to_json}"
|
|
178
|
+
{}
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def get_flags(distinct_id, groups = {}, person_properties = {}, group_properties = {}, flag_keys = nil,
|
|
183
|
+
disable_geoip = nil)
|
|
184
|
+
flag_keys = flag_keys.map(&:to_s) if flag_keys.is_a?(Array)
|
|
185
|
+
request_data = {
|
|
186
|
+
distinct_id: distinct_id,
|
|
187
|
+
groups: groups,
|
|
188
|
+
person_properties: person_properties,
|
|
189
|
+
group_properties: group_properties
|
|
190
|
+
}
|
|
191
|
+
request_data[:flag_keys_to_evaluate] = flag_keys if flag_keys && !flag_keys.empty?
|
|
192
|
+
request_data[:geoip_disable] = disable_geoip unless disable_geoip.nil?
|
|
193
|
+
|
|
194
|
+
flags_response = _request_feature_flag_evaluation(request_data)
|
|
195
|
+
|
|
196
|
+
# Only normalize if we have flags in the response
|
|
197
|
+
if flags_response[:flags]
|
|
198
|
+
# v4 format
|
|
199
|
+
flags_hash = flags_response[:flags].transform_values do |flag|
|
|
200
|
+
FeatureFlag.new(flag)
|
|
201
|
+
end
|
|
202
|
+
flags_response[:flags] = flags_hash
|
|
203
|
+
# Filter out flags that failed evaluation to avoid overwriting cached values
|
|
204
|
+
successful_flags = flags_hash.reject { |_, flag| flag.failed == true }
|
|
205
|
+
flags_response[:featureFlags] = successful_flags.transform_values(&:get_value).transform_keys(&:to_sym)
|
|
206
|
+
flags_response[:featureFlagPayloads] = successful_flags.transform_values(&:payload).transform_keys(&:to_sym)
|
|
207
|
+
elsif flags_response[:featureFlags]
|
|
208
|
+
# v3 format
|
|
209
|
+
flags_response[:featureFlags] = flags_response[:featureFlags] || {}
|
|
210
|
+
flags_response[:featureFlagPayloads] = flags_response[:featureFlagPayloads] || {}
|
|
211
|
+
flags_response[:flags] = flags_response[:featureFlags].to_h do |key, value|
|
|
212
|
+
[key, FeatureFlag.from_value_and_payload(key, value, flags_response[:featureFlagPayloads][key])]
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
flags_response
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def get_remote_config_payload(flag_key)
|
|
220
|
+
_request_remote_config_payload(flag_key.to_s)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def get_feature_flag(
|
|
224
|
+
key,
|
|
225
|
+
distinct_id,
|
|
226
|
+
groups = {},
|
|
227
|
+
person_properties = {},
|
|
228
|
+
group_properties = {},
|
|
229
|
+
only_evaluate_locally = false,
|
|
230
|
+
snapshot: nil
|
|
231
|
+
)
|
|
232
|
+
key = key.to_s
|
|
233
|
+
|
|
234
|
+
# make sure they're loaded on first run
|
|
235
|
+
load_feature_flags
|
|
236
|
+
snapshot ||= _evaluation_snapshot
|
|
237
|
+
|
|
238
|
+
symbolize_keys! groups
|
|
239
|
+
symbolize_keys! person_properties
|
|
240
|
+
symbolize_keys! group_properties
|
|
241
|
+
|
|
242
|
+
group_properties.each_value do |value|
|
|
243
|
+
symbolize_keys!(value)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
response = nil
|
|
247
|
+
payload = nil
|
|
248
|
+
feature_flag = snapshot[:flags_by_key]&.[](key)
|
|
249
|
+
|
|
250
|
+
unless feature_flag.nil?
|
|
251
|
+
begin
|
|
252
|
+
response = _compute_flag_locally(feature_flag, distinct_id, groups, person_properties, group_properties,
|
|
253
|
+
snapshot: snapshot)
|
|
254
|
+
payload = _compute_flag_payload_locally(key, response, snapshot: snapshot) unless response.nil?
|
|
255
|
+
logger.debug "Successfully computed flag locally: #{key} -> #{response}"
|
|
256
|
+
rescue RequiresServerEvaluation, InconclusiveMatchError => e
|
|
257
|
+
logger.debug "Failed to compute flag #{key} locally: #{e}"
|
|
258
|
+
rescue StandardError => e
|
|
259
|
+
@on_error.call(-1, "Error computing flag locally: #{e}. #{e.backtrace.join("\n")}")
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
flag_was_locally_evaluated = !response.nil?
|
|
264
|
+
# Whether the flag is linked to an experiment, as reported by the server.
|
|
265
|
+
# Locally-evaluated flags carry it in the stored definition; remotely
|
|
266
|
+
# evaluated flags carry it in the response metadata. nil when the server
|
|
267
|
+
# (an older deployment) does not report it.
|
|
268
|
+
has_experiment = feature_flag[:has_experiment] if flag_was_locally_evaluated
|
|
269
|
+
# Server-controlled gate for minimal `$feature_flag_called` events.
|
|
270
|
+
# Locally-evaluated flags read it from the definitions payload; remotely
|
|
271
|
+
# evaluated flags read it from the /flags response. nil when the signal
|
|
272
|
+
# is unavailable, which fails safe to the full event.
|
|
273
|
+
minimal_flag_called_events = snapshot[:minimal_flag_called_events] if flag_was_locally_evaluated
|
|
274
|
+
|
|
275
|
+
request_id = nil
|
|
276
|
+
evaluated_at = nil
|
|
277
|
+
feature_flag_error = nil
|
|
278
|
+
|
|
279
|
+
if !flag_was_locally_evaluated && !only_evaluate_locally
|
|
280
|
+
begin
|
|
281
|
+
errors = []
|
|
282
|
+
flags_data = get_all_flags_and_payloads(distinct_id, groups, person_properties, group_properties,
|
|
283
|
+
only_evaluate_locally, true)
|
|
284
|
+
if flags_data.key?(:featureFlags)
|
|
285
|
+
flags = stringify_keys(flags_data[:featureFlags] || {})
|
|
286
|
+
payloads = stringify_keys(flags_data[:featureFlagPayloads] || {})
|
|
287
|
+
request_id = flags_data[:requestId]
|
|
288
|
+
evaluated_at = flags_data[:evaluatedAt]
|
|
289
|
+
else
|
|
290
|
+
logger.debug "Missing feature flags key: #{flags_data.to_json}"
|
|
291
|
+
flags = {}
|
|
292
|
+
payloads = {}
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
status = flags_data[:status]
|
|
296
|
+
errors << FeatureFlagError.api_error(status) if status && status >= 400
|
|
297
|
+
errors << FeatureFlagError::ERRORS_WHILE_COMPUTING if flags_data[:errorsWhileComputingFlags]
|
|
298
|
+
errors << FeatureFlagError::QUOTA_LIMITED if flags_data[:quotaLimited]&.include?('feature_flags')
|
|
299
|
+
errors << FeatureFlagError::FLAG_MISSING unless flags.key?(key.to_s)
|
|
300
|
+
|
|
301
|
+
response = flags[key]
|
|
302
|
+
response = false if response.nil?
|
|
303
|
+
payload = payloads[key]
|
|
304
|
+
feature_flag_error = errors.join(',') unless errors.empty?
|
|
305
|
+
|
|
306
|
+
flag_detail = flags_data[:flagDetails]&.[](key.to_sym)
|
|
307
|
+
has_experiment = flag_detail&.metadata&.has_experiment
|
|
308
|
+
minimal_flag_called_events = flags_data[:minimalFlagCalledEvents]
|
|
309
|
+
|
|
310
|
+
logger.debug "Successfully computed flag remotely: #{key} -> #{response}"
|
|
311
|
+
rescue Timeout::Error => e
|
|
312
|
+
@on_error.call(-1, "Timeout while fetching flags remotely: #{e}")
|
|
313
|
+
feature_flag_error = FeatureFlagError::TIMEOUT
|
|
314
|
+
rescue Errno::ECONNRESET, Errno::ECONNREFUSED, EOFError, SocketError => e
|
|
315
|
+
@on_error.call(-1, "Connection error while fetching flags remotely: #{e}")
|
|
316
|
+
feature_flag_error = FeatureFlagError::CONNECTION_ERROR
|
|
317
|
+
rescue StandardError => e
|
|
318
|
+
@on_error.call(-1, "Error computing flag remotely: #{e}. #{e.backtrace.join("\n")}")
|
|
319
|
+
feature_flag_error = FeatureFlagError::UNKNOWN_ERROR
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
[response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload, has_experiment,
|
|
324
|
+
minimal_flag_called_events]
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def get_all_flags(
|
|
328
|
+
distinct_id,
|
|
329
|
+
groups = {},
|
|
330
|
+
person_properties = {},
|
|
331
|
+
group_properties = {},
|
|
332
|
+
only_evaluate_locally = false
|
|
333
|
+
)
|
|
334
|
+
if @quota_limited.true?
|
|
335
|
+
logger.debug 'Not fetching flags from server - quota limited'
|
|
336
|
+
return {}
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# returns a string hash of all flags
|
|
340
|
+
response = get_all_flags_and_payloads(
|
|
341
|
+
distinct_id,
|
|
342
|
+
groups,
|
|
343
|
+
person_properties,
|
|
344
|
+
group_properties,
|
|
345
|
+
only_evaluate_locally
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
response[:featureFlags]
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def get_all_flags_and_payloads(
|
|
352
|
+
distinct_id,
|
|
353
|
+
groups = {},
|
|
354
|
+
person_properties = {},
|
|
355
|
+
group_properties = {},
|
|
356
|
+
only_evaluate_locally = false,
|
|
357
|
+
raise_on_error = false
|
|
358
|
+
)
|
|
359
|
+
load_feature_flags
|
|
360
|
+
snapshot = _evaluation_snapshot
|
|
361
|
+
|
|
362
|
+
flags = {}
|
|
363
|
+
payloads = {}
|
|
364
|
+
fallback_to_server = snapshot[:flags].empty?
|
|
365
|
+
request_id = nil # Only for /flags requests
|
|
366
|
+
evaluated_at = nil # Only for /flags requests
|
|
367
|
+
|
|
368
|
+
snapshot[:flags].each do |flag|
|
|
369
|
+
match_value = _compute_flag_locally(flag, distinct_id, groups, person_properties, group_properties,
|
|
370
|
+
snapshot: snapshot)
|
|
371
|
+
flags[flag[:key]] = match_value
|
|
372
|
+
match_payload = _compute_flag_payload_locally(flag[:key], match_value, snapshot: snapshot)
|
|
373
|
+
payloads[flag[:key]] = match_payload if match_payload
|
|
374
|
+
rescue RequiresServerEvaluation, InconclusiveMatchError
|
|
375
|
+
fallback_to_server = true
|
|
376
|
+
rescue StandardError => e
|
|
377
|
+
@on_error.call(-1, "Error computing flag locally: #{e}. #{e.backtrace.join("\n")} ")
|
|
378
|
+
fallback_to_server = true
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
errors_while_computing = false
|
|
382
|
+
quota_limited = nil
|
|
383
|
+
status_code = nil
|
|
384
|
+
flag_details = nil
|
|
385
|
+
minimal_flag_called_events = nil
|
|
386
|
+
|
|
387
|
+
if fallback_to_server && !only_evaluate_locally
|
|
388
|
+
begin
|
|
389
|
+
flags_and_payloads = get_flags(distinct_id, groups, person_properties, group_properties)
|
|
390
|
+
flag_details = flags_and_payloads[:flags]
|
|
391
|
+
errors_while_computing = flags_and_payloads[:errorsWhileComputingFlags] || false
|
|
392
|
+
quota_limited = flags_and_payloads[:quotaLimited]
|
|
393
|
+
status_code = flags_and_payloads[:status]
|
|
394
|
+
|
|
395
|
+
unless flags_and_payloads.key?(:featureFlags)
|
|
396
|
+
raise StandardError, "Error flags response: #{flags_and_payloads}"
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
request_id = flags_and_payloads[:requestId]
|
|
400
|
+
evaluated_at = flags_and_payloads[:evaluatedAt]
|
|
401
|
+
minimal_flag_called_events = flags_and_payloads[:minimalFlagCalledEvents]
|
|
402
|
+
|
|
403
|
+
# Check if feature_flags are quota limited
|
|
404
|
+
if quota_limited&.include?('feature_flags')
|
|
405
|
+
logger.warn '[FEATURE FLAGS] Quota limited for feature flags'
|
|
406
|
+
flags = {}
|
|
407
|
+
payloads = {}
|
|
408
|
+
else
|
|
409
|
+
server_flags = stringify_keys(flags_and_payloads[:featureFlags] || {})
|
|
410
|
+
server_payloads = stringify_keys(flags_and_payloads[:featureFlagPayloads] || {})
|
|
411
|
+
|
|
412
|
+
if errors_while_computing
|
|
413
|
+
# Merge server results into locally-evaluated results so that flags which
|
|
414
|
+
# failed server-side evaluation (already filtered out by get_flags) don't
|
|
415
|
+
# overwrite valid locally-evaluated values.
|
|
416
|
+
flags = stringify_keys(flags).merge(server_flags)
|
|
417
|
+
payloads = stringify_keys(payloads).merge(server_payloads)
|
|
418
|
+
else
|
|
419
|
+
flags = server_flags
|
|
420
|
+
payloads = server_payloads
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
rescue StandardError => e
|
|
424
|
+
@on_error.call(-1, "Error computing flag remotely: #{e}")
|
|
425
|
+
raise if raise_on_error
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
{
|
|
430
|
+
featureFlags: flags,
|
|
431
|
+
featureFlagPayloads: payloads,
|
|
432
|
+
flagDetails: flag_details,
|
|
433
|
+
requestId: request_id,
|
|
434
|
+
evaluatedAt: evaluated_at,
|
|
435
|
+
errorsWhileComputingFlags: errors_while_computing,
|
|
436
|
+
quotaLimited: quota_limited,
|
|
437
|
+
status: status_code,
|
|
438
|
+
minimalFlagCalledEvents: minimal_flag_called_events
|
|
439
|
+
}
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def get_feature_flag_payload(
|
|
443
|
+
key,
|
|
444
|
+
distinct_id,
|
|
445
|
+
match_value = nil,
|
|
446
|
+
groups = {},
|
|
447
|
+
person_properties = {},
|
|
448
|
+
group_properties = {},
|
|
449
|
+
only_evaluate_locally = false
|
|
450
|
+
)
|
|
451
|
+
key = key.to_s
|
|
452
|
+
load_feature_flags
|
|
453
|
+
snapshot = _evaluation_snapshot
|
|
454
|
+
|
|
455
|
+
if match_value.nil?
|
|
456
|
+
match_value = get_feature_flag(
|
|
457
|
+
key,
|
|
458
|
+
distinct_id,
|
|
459
|
+
groups,
|
|
460
|
+
person_properties,
|
|
461
|
+
group_properties,
|
|
462
|
+
true,
|
|
463
|
+
snapshot: snapshot
|
|
464
|
+
)[0]
|
|
465
|
+
end
|
|
466
|
+
response = nil
|
|
467
|
+
response = _compute_flag_payload_locally(key, match_value, snapshot: snapshot) unless match_value.nil?
|
|
468
|
+
if response.nil? && !only_evaluate_locally
|
|
469
|
+
flags_payloads = get_feature_payloads(distinct_id, groups, person_properties, group_properties)
|
|
470
|
+
response = flags_payloads[key.downcase] || nil
|
|
471
|
+
end
|
|
472
|
+
response
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def shutdown_poller
|
|
476
|
+
@task.shutdown
|
|
477
|
+
return unless @flag_definition_cache_provider
|
|
478
|
+
|
|
479
|
+
begin
|
|
480
|
+
@flag_definition_cache_provider.shutdown
|
|
481
|
+
rescue StandardError => e
|
|
482
|
+
logger.error("[FEATURE FLAGS] Cache provider shutdown error: #{e}")
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Class methods
|
|
487
|
+
|
|
488
|
+
def self.compare(lhs, rhs, operator)
|
|
489
|
+
case operator
|
|
490
|
+
when 'gt'
|
|
491
|
+
lhs > rhs
|
|
492
|
+
when 'gte'
|
|
493
|
+
lhs >= rhs
|
|
494
|
+
when 'lt'
|
|
495
|
+
lhs < rhs
|
|
496
|
+
when 'lte'
|
|
497
|
+
lhs <= rhs
|
|
498
|
+
else
|
|
499
|
+
raise "Invalid operator: #{operator}"
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def self.relative_date_parse_for_feature_flag_matching(value)
|
|
504
|
+
match = /^-?([0-9]+)([a-z])$/.match(value)
|
|
505
|
+
parsed_dt = DateTime.now.new_offset(0)
|
|
506
|
+
return unless match
|
|
507
|
+
|
|
508
|
+
number = match[1].to_i
|
|
509
|
+
|
|
510
|
+
if number >= 10_000
|
|
511
|
+
# Guard against overflow, disallow numbers greater than 10_000
|
|
512
|
+
return nil
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
interval = match[2]
|
|
516
|
+
case interval
|
|
517
|
+
when 'h'
|
|
518
|
+
parsed_dt -= (number / 24.0)
|
|
519
|
+
when 'd'
|
|
520
|
+
parsed_dt = parsed_dt.prev_day(number)
|
|
521
|
+
when 'w'
|
|
522
|
+
parsed_dt = parsed_dt.prev_day(number * 7)
|
|
523
|
+
when 'm'
|
|
524
|
+
parsed_dt = parsed_dt.prev_month(number)
|
|
525
|
+
when 'y'
|
|
526
|
+
parsed_dt = parsed_dt.prev_year(number)
|
|
527
|
+
else
|
|
528
|
+
return nil
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
parsed_dt
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# Parse a single semver numeric identifier, rejecting empty, non-digit, or
|
|
535
|
+
# leading-zero values per semver 2.0.0 §2.
|
|
536
|
+
def self.parse_semver_numeric(part)
|
|
537
|
+
raise InconclusiveMatchError, 'Invalid semver format' if part.nil? || part.empty? || part !~ /^\d+$/
|
|
538
|
+
# Semver 2.0.0 §2: numeric identifiers MUST NOT include leading zeros.
|
|
539
|
+
raise InconclusiveMatchError, 'Invalid semver format' if part.length > 1 && part[0] == '0'
|
|
540
|
+
|
|
541
|
+
part.to_i
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Parse a semver string into a comparable [major, minor, patch] integer array.
|
|
545
|
+
# Handles v-prefix, whitespace, pre-release suffixes. Defaults missing components to 0.
|
|
546
|
+
def self.parse_semver(value)
|
|
547
|
+
raise InconclusiveMatchError, 'Invalid semver format' if value.nil?
|
|
548
|
+
|
|
549
|
+
text = value.to_s.strip.sub(/^[vV]/, '')
|
|
550
|
+
|
|
551
|
+
raise InconclusiveMatchError, 'Invalid semver format' if text.empty?
|
|
552
|
+
|
|
553
|
+
# Strip pre-release and build metadata suffixes
|
|
554
|
+
text = text.split('-')[0].split('+')[0]
|
|
555
|
+
parts = text.split('.')
|
|
556
|
+
|
|
557
|
+
raise InconclusiveMatchError, 'Invalid semver format' if parts.empty? || parts[0].to_s.empty?
|
|
558
|
+
|
|
559
|
+
major = parse_semver_numeric(parts[0])
|
|
560
|
+
minor = parts.length > 1 ? parse_semver_numeric(parts[1]) : 0
|
|
561
|
+
patch = parts.length > 2 ? parse_semver_numeric(parts[2]) : 0
|
|
562
|
+
|
|
563
|
+
[major, minor, patch]
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Returns bounds for tilde (~) range:
|
|
567
|
+
# ~X → >=X.0.0 <(X+1).0.0
|
|
568
|
+
# ~X.Y → >=X.Y.0 <X.(Y+1).0
|
|
569
|
+
# ~X.Y.Z → >=X.Y.Z <X.(Y+1).0
|
|
570
|
+
def self.semver_tilde_bounds(value)
|
|
571
|
+
major, minor, patch = parse_semver(value)
|
|
572
|
+
lower = [major, minor, patch]
|
|
573
|
+
|
|
574
|
+
# Determine how many components were provided
|
|
575
|
+
text = value.to_s.strip.sub(/^[vV]/, '')
|
|
576
|
+
text = text.split('-')[0].split('+')[0]
|
|
577
|
+
component_count = text.split('.').length
|
|
578
|
+
|
|
579
|
+
upper = if component_count == 1
|
|
580
|
+
# Major-only: bump major
|
|
581
|
+
[major + 1, 0, 0]
|
|
582
|
+
else
|
|
583
|
+
# Major.minor or major.minor.patch: bump minor
|
|
584
|
+
[major, minor + 1, 0]
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
[lower, upper]
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# Returns bounds for caret (^) range per semver spec:
|
|
591
|
+
# ^X.Y.Z where X > 0 → >=X.Y.Z <(X+1).0.0
|
|
592
|
+
# ^0.Y.Z where Y > 0 → >=0.Y.Z <0.(Y+1).0
|
|
593
|
+
# ^0.0.Z → >=0.0.Z <0.0.(Z+1)
|
|
594
|
+
def self.semver_caret_bounds(value)
|
|
595
|
+
major, minor, patch = parse_semver(value)
|
|
596
|
+
lower = [major, minor, patch]
|
|
597
|
+
|
|
598
|
+
upper = if major.positive?
|
|
599
|
+
[major + 1, 0, 0]
|
|
600
|
+
elsif minor.positive?
|
|
601
|
+
[0, minor + 1, 0]
|
|
602
|
+
else
|
|
603
|
+
[0, 0, patch + 1]
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
[lower, upper]
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# Returns bounds for wildcard (*) range:
|
|
610
|
+
# X.* or X → >=X.0.0 <(X+1).0.0
|
|
611
|
+
# X.Y.* → >=X.Y.0 <X.(Y+1).0
|
|
612
|
+
def self.semver_wildcard_bounds(value)
|
|
613
|
+
cleaned = value.to_s.strip.sub(/^[vV]/, '').gsub('*', '').chomp('.')
|
|
614
|
+
parts = cleaned.split('.').reject(&:empty?)
|
|
615
|
+
|
|
616
|
+
raise InconclusiveMatchError, 'Invalid semver wildcard format' if parts.empty?
|
|
617
|
+
|
|
618
|
+
numeric = parts.map do |part|
|
|
619
|
+
parse_semver_numeric(part)
|
|
620
|
+
rescue InconclusiveMatchError
|
|
621
|
+
raise InconclusiveMatchError, 'Invalid semver wildcard format'
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
major = numeric[0]
|
|
625
|
+
case numeric.length
|
|
626
|
+
when 1
|
|
627
|
+
[[major, 0, 0], [major + 1, 0, 0]]
|
|
628
|
+
when 2
|
|
629
|
+
minor = numeric[1]
|
|
630
|
+
[[major, minor, 0], [major, minor + 1, 0]]
|
|
631
|
+
else
|
|
632
|
+
minor = numeric[1]
|
|
633
|
+
patch = numeric[2]
|
|
634
|
+
[[major, minor, patch], [major, minor, patch + 1]]
|
|
635
|
+
end
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# Service legacy classifies the entire filter, not individual array members.
|
|
639
|
+
def self.boolean_like?(value)
|
|
640
|
+
case value
|
|
641
|
+
when true, false then true
|
|
642
|
+
when String then %w[true false].include?(value.downcase)
|
|
643
|
+
when Array then value.all? { |member| boolean_like?(member) }
|
|
644
|
+
else false
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
def self.legacy_truthy?(value)
|
|
649
|
+
case value
|
|
650
|
+
when true then true
|
|
651
|
+
when String then value.downcase == 'true'
|
|
652
|
+
when Array then value.all? { |member| legacy_truthy?(member) }
|
|
653
|
+
else false
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def self.sorted_composite(value)
|
|
658
|
+
case value
|
|
659
|
+
when Hash
|
|
660
|
+
value.transform_keys(&:to_s).sort.to_h.transform_values { |member| sorted_composite(member) }
|
|
661
|
+
when Array
|
|
662
|
+
value.map { |member| sorted_composite(member) }
|
|
663
|
+
else
|
|
664
|
+
value
|
|
665
|
+
end
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def self.property_string(value)
|
|
669
|
+
# Keep numeric normalization unchanged; null and composites need JSON rather
|
|
670
|
+
# than Ruby's nil.to_s / Array#to_s representations.
|
|
671
|
+
case value
|
|
672
|
+
when nil, Array, Hash then JSON.generate(sorted_composite(value))
|
|
673
|
+
else value.to_s
|
|
674
|
+
end.downcase
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def self.exact_property_match?(filter, value, property_matching_version)
|
|
678
|
+
return legacy_truthy?(filter) == legacy_truthy?(value) if property_matching_version != 2 && boolean_like?(filter)
|
|
679
|
+
return legacy_truthy?(value) if filter.is_a?(Array) && filter.empty?
|
|
680
|
+
|
|
681
|
+
if filter.is_a?(Array)
|
|
682
|
+
filter.any? { |member| property_string(member) == property_string(value) }
|
|
683
|
+
else
|
|
684
|
+
property_string(filter) == property_string(value)
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
private_class_method :boolean_like?, :legacy_truthy?, :sorted_composite, :property_string, :exact_property_match?
|
|
689
|
+
|
|
690
|
+
def self.match_property(property, property_values, cohort_properties = {}, property_matching_version: 1)
|
|
691
|
+
# only looks for matches where key exists in property_values
|
|
692
|
+
|
|
693
|
+
Txlemetry::Utils.symbolize_keys! property
|
|
694
|
+
Txlemetry::Utils.symbolize_keys! property_values
|
|
695
|
+
|
|
696
|
+
# Handle cohort properties
|
|
697
|
+
if extract_value(property, :type) == 'cohort'
|
|
698
|
+
return match_cohort(property, property_values, cohort_properties,
|
|
699
|
+
property_matching_version: property_matching_version)
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
key = property[:key].to_sym
|
|
703
|
+
value = property[:value]
|
|
704
|
+
operator = property[:operator] || 'exact'
|
|
705
|
+
|
|
706
|
+
if !property_values.key?(key)
|
|
707
|
+
raise InconclusiveMatchError, "Property #{key} not found in property_values"
|
|
708
|
+
elsif operator == 'is_not_set'
|
|
709
|
+
return false
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
override_value = property_values[key]
|
|
713
|
+
|
|
714
|
+
case operator
|
|
715
|
+
when 'exact', 'is_not'
|
|
716
|
+
matches = exact_property_match?(value, override_value, property_matching_version)
|
|
717
|
+
operator == 'exact' ? matches : !matches
|
|
718
|
+
when 'is_set'
|
|
719
|
+
property_values.key?(key)
|
|
720
|
+
when 'icontains'
|
|
721
|
+
override_value.to_s.downcase(:ascii).include?(value.to_s.downcase(:ascii))
|
|
722
|
+
when 'not_icontains'
|
|
723
|
+
!override_value.to_s.downcase(:ascii).include?(value.to_s.downcase(:ascii))
|
|
724
|
+
when 'starts_with'
|
|
725
|
+
override_value.to_s.downcase(:ascii).start_with?(value.to_s.downcase(:ascii))
|
|
726
|
+
when 'not_starts_with'
|
|
727
|
+
!override_value.to_s.downcase(:ascii).start_with?(value.to_s.downcase(:ascii))
|
|
728
|
+
when 'ends_with'
|
|
729
|
+
override_value.to_s.downcase(:ascii).end_with?(value.to_s.downcase(:ascii))
|
|
730
|
+
when 'not_ends_with'
|
|
731
|
+
!override_value.to_s.downcase(:ascii).end_with?(value.to_s.downcase(:ascii))
|
|
732
|
+
when 'regex'
|
|
733
|
+
Txlemetry::Utils.is_valid_regex(value.to_s) && !Regexp.new(value.to_s).match(override_value.to_s).nil?
|
|
734
|
+
when 'not_regex'
|
|
735
|
+
Txlemetry::Utils.is_valid_regex(value.to_s) && Regexp.new(value.to_s).match(override_value.to_s).nil?
|
|
736
|
+
when 'gt', 'gte', 'lt', 'lte'
|
|
737
|
+
parsed_value = nil
|
|
738
|
+
begin
|
|
739
|
+
parsed_value = Float(value)
|
|
740
|
+
rescue StandardError # rubocop:disable Lint/SuppressedException
|
|
741
|
+
end
|
|
742
|
+
if !parsed_value.nil? && !override_value.nil?
|
|
743
|
+
if override_value.is_a?(String)
|
|
744
|
+
compare(override_value, value.to_s, operator)
|
|
745
|
+
else
|
|
746
|
+
compare(override_value, parsed_value, operator)
|
|
747
|
+
end
|
|
748
|
+
else
|
|
749
|
+
compare(override_value.to_s, value.to_s, operator)
|
|
750
|
+
end
|
|
751
|
+
when 'is_date_before', 'is_date_after'
|
|
752
|
+
override_date = Txlemetry::Utils.convert_to_datetime(override_value.to_s)
|
|
753
|
+
parsed_date = relative_date_parse_for_feature_flag_matching(value.to_s)
|
|
754
|
+
|
|
755
|
+
parsed_date = Txlemetry::Utils.convert_to_datetime(value.to_s) if parsed_date.nil?
|
|
756
|
+
|
|
757
|
+
raise InconclusiveMatchError, 'Invalid date format' unless parsed_date
|
|
758
|
+
|
|
759
|
+
if operator == 'is_date_before'
|
|
760
|
+
override_date < parsed_date
|
|
761
|
+
elsif operator == 'is_date_after'
|
|
762
|
+
override_date > parsed_date
|
|
763
|
+
end
|
|
764
|
+
when 'semver_eq', 'semver_neq', 'semver_gt', 'semver_gte', 'semver_lt', 'semver_lte'
|
|
765
|
+
override_parsed = parse_semver(override_value)
|
|
766
|
+
flag_parsed = parse_semver(value)
|
|
767
|
+
|
|
768
|
+
case operator
|
|
769
|
+
when 'semver_eq'
|
|
770
|
+
override_parsed == flag_parsed
|
|
771
|
+
when 'semver_neq'
|
|
772
|
+
override_parsed != flag_parsed
|
|
773
|
+
when 'semver_gt'
|
|
774
|
+
(override_parsed <=> flag_parsed) == 1
|
|
775
|
+
when 'semver_gte'
|
|
776
|
+
(override_parsed <=> flag_parsed) >= 0
|
|
777
|
+
when 'semver_lt'
|
|
778
|
+
(override_parsed <=> flag_parsed) == -1
|
|
779
|
+
when 'semver_lte'
|
|
780
|
+
(override_parsed <=> flag_parsed) <= 0
|
|
781
|
+
end
|
|
782
|
+
when 'semver_tilde'
|
|
783
|
+
override_parsed = parse_semver(override_value)
|
|
784
|
+
lower, upper = semver_tilde_bounds(value)
|
|
785
|
+
(override_parsed <=> lower) >= 0 && (override_parsed <=> upper) == -1
|
|
786
|
+
when 'semver_caret'
|
|
787
|
+
override_parsed = parse_semver(override_value)
|
|
788
|
+
lower, upper = semver_caret_bounds(value)
|
|
789
|
+
(override_parsed <=> lower) >= 0 && (override_parsed <=> upper) == -1
|
|
790
|
+
when 'semver_wildcard'
|
|
791
|
+
override_parsed = parse_semver(override_value)
|
|
792
|
+
lower, upper = semver_wildcard_bounds(value)
|
|
793
|
+
(override_parsed <=> lower) >= 0 && (override_parsed <=> upper) == -1
|
|
794
|
+
else
|
|
795
|
+
raise InconclusiveMatchError, "Unknown operator: #{operator}"
|
|
796
|
+
end
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
def self.match_cohort(property, property_values, cohort_properties, property_matching_version: 1)
|
|
800
|
+
# Cohort properties are in the form of property groups like this:
|
|
801
|
+
# {
|
|
802
|
+
# "cohort_id" => {
|
|
803
|
+
# "type" => "AND|OR",
|
|
804
|
+
# "values" => [{
|
|
805
|
+
# "key" => "property_name", "value" => "property_value"
|
|
806
|
+
# }]
|
|
807
|
+
# }
|
|
808
|
+
# }
|
|
809
|
+
cohort_id = extract_value(property, :value).to_s
|
|
810
|
+
property_group = find_cohort_property(cohort_properties, cohort_id)
|
|
811
|
+
|
|
812
|
+
unless property_group
|
|
813
|
+
raise RequiresServerEvaluation,
|
|
814
|
+
"cohort #{cohort_id} not found in local cohorts - likely a static cohort that requires server evaluation"
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
match_property_group(property_group, property_values, cohort_properties,
|
|
818
|
+
property_matching_version: property_matching_version)
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
def self.match_property_group(property_group, property_values, cohort_properties, property_matching_version: 1)
|
|
822
|
+
return true if property_group.nil? || property_group.empty?
|
|
823
|
+
|
|
824
|
+
group_type = extract_value(property_group, :type)
|
|
825
|
+
properties = extract_value(property_group, :values)
|
|
826
|
+
|
|
827
|
+
return true if properties.nil? || properties.empty?
|
|
828
|
+
|
|
829
|
+
if nested_property_group?(properties)
|
|
830
|
+
match_nested_property_group(properties, group_type, property_values, cohort_properties,
|
|
831
|
+
property_matching_version: property_matching_version)
|
|
832
|
+
else
|
|
833
|
+
match_regular_property_group(properties, group_type, property_values, cohort_properties,
|
|
834
|
+
property_matching_version: property_matching_version)
|
|
835
|
+
end
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
def self.extract_value(hash, key)
|
|
839
|
+
return nil unless hash.is_a?(Hash)
|
|
840
|
+
|
|
841
|
+
hash[key] || hash[key.to_s]
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
def self.find_cohort_property(cohort_properties, cohort_id)
|
|
845
|
+
return nil unless cohort_properties.is_a?(Hash)
|
|
846
|
+
|
|
847
|
+
cohort_properties[cohort_id] || cohort_properties[cohort_id.to_sym]
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
def self.nested_property_group?(properties)
|
|
851
|
+
return false unless properties&.any?
|
|
852
|
+
|
|
853
|
+
first_property = properties[0]
|
|
854
|
+
return false unless first_property.is_a?(Hash)
|
|
855
|
+
|
|
856
|
+
first_property.key?(:values) || first_property.key?('values')
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
def self.match_nested_property_group(properties, group_type, property_values, cohort_properties,
|
|
860
|
+
property_matching_version: 1)
|
|
861
|
+
case group_type
|
|
862
|
+
when 'AND'
|
|
863
|
+
properties.each do |property|
|
|
864
|
+
return false unless match_property_group(property, property_values, cohort_properties,
|
|
865
|
+
property_matching_version: property_matching_version)
|
|
866
|
+
end
|
|
867
|
+
true
|
|
868
|
+
when 'OR'
|
|
869
|
+
properties.each do |property|
|
|
870
|
+
return true if match_property_group(property, property_values, cohort_properties,
|
|
871
|
+
property_matching_version: property_matching_version)
|
|
872
|
+
end
|
|
873
|
+
false
|
|
874
|
+
else
|
|
875
|
+
raise InconclusiveMatchError, "Unknown property group type: #{group_type}"
|
|
876
|
+
end
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
def self.match_regular_property_group(properties, group_type, property_values, cohort_properties,
|
|
880
|
+
property_matching_version: 1)
|
|
881
|
+
# Validate group type upfront
|
|
882
|
+
raise InconclusiveMatchError, "Unknown property group type: #{group_type}" unless %w[AND OR].include?(group_type)
|
|
883
|
+
|
|
884
|
+
error_matching_locally = false
|
|
885
|
+
|
|
886
|
+
properties.each do |prop|
|
|
887
|
+
Txlemetry::Utils.symbolize_keys!(prop)
|
|
888
|
+
|
|
889
|
+
matches = match_property(prop, property_values, cohort_properties,
|
|
890
|
+
property_matching_version: property_matching_version)
|
|
891
|
+
|
|
892
|
+
negated = prop[:negation] || false
|
|
893
|
+
final_result = negated ? !matches : matches
|
|
894
|
+
|
|
895
|
+
# Short-circuit based on group type
|
|
896
|
+
if group_type == 'AND'
|
|
897
|
+
return false unless final_result
|
|
898
|
+
elsif final_result # group_type == 'OR'
|
|
899
|
+
return true
|
|
900
|
+
end
|
|
901
|
+
rescue RequiresServerEvaluation
|
|
902
|
+
# Immediately propagate - this condition requires server-side data
|
|
903
|
+
raise
|
|
904
|
+
rescue InconclusiveMatchError => e
|
|
905
|
+
Txlemetry::Logging.logger&.debug("Failed to compute property #{prop} locally: #{e}")
|
|
906
|
+
error_matching_locally = true
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
raise InconclusiveMatchError, "can't match cohort without a given cohort property value" if error_matching_locally
|
|
910
|
+
|
|
911
|
+
# If we reach here, return default based on group type
|
|
912
|
+
group_type == 'AND'
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
# Evaluates a flag dependency property according to the dependency chain algorithm.
|
|
916
|
+
#
|
|
917
|
+
# @param property [Hash] Flag property with type="flag" and dependency_chain
|
|
918
|
+
# @param evaluation_cache [Hash] Cache for storing evaluation results
|
|
919
|
+
# @param distinct_id [String] The distinct ID being evaluated
|
|
920
|
+
# @param properties [Hash] Person properties for evaluation
|
|
921
|
+
# @param cohort_properties [Hash] Cohort properties for evaluation
|
|
922
|
+
# @return [Boolean] True if all dependencies in the chain evaluate to true, false otherwise
|
|
923
|
+
def evaluate_flag_dependency(property, evaluation_cache, distinct_id, properties, cohort_properties,
|
|
924
|
+
snapshot: _evaluation_snapshot)
|
|
925
|
+
if property[:operator] != 'flag_evaluates_to'
|
|
926
|
+
# Should never happen, but just in case
|
|
927
|
+
raise InconclusiveMatchError, "Operator #{property[:operator]} not supported for flag dependencies"
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
if snapshot[:flags_by_key].nil? || evaluation_cache.nil?
|
|
931
|
+
# Cannot evaluate flag dependencies without required context
|
|
932
|
+
raise InconclusiveMatchError,
|
|
933
|
+
"Cannot evaluate flag dependency on '#{property[:key] || 'unknown'}' " \
|
|
934
|
+
'without feature flags loaded or evaluation_cache'
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
# Check if dependency_chain is present - it should always be provided for flag dependencies
|
|
938
|
+
unless property.key?(:dependency_chain)
|
|
939
|
+
# Missing dependency_chain indicates malformed server data
|
|
940
|
+
raise InconclusiveMatchError,
|
|
941
|
+
"Flag dependency property for '#{property[:key] || 'unknown'}' " \
|
|
942
|
+
"is missing required 'dependency_chain' field"
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
dependency_chain = property[:dependency_chain]
|
|
946
|
+
|
|
947
|
+
# Handle circular dependency (empty chain means circular)
|
|
948
|
+
if dependency_chain.empty?
|
|
949
|
+
Txlemetry::Logging.logger&.debug("Circular dependency detected for flag: #{property[:key]}")
|
|
950
|
+
raise InconclusiveMatchError,
|
|
951
|
+
"Circular dependency detected for flag '#{property[:key] || 'unknown'}'"
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
# Evaluate all dependencies in the chain order
|
|
955
|
+
dependency_chain.each do |dep_flag_key|
|
|
956
|
+
unless evaluation_cache.key?(dep_flag_key)
|
|
957
|
+
# Need to evaluate this dependency first
|
|
958
|
+
dep_flag = snapshot[:flags_by_key][dep_flag_key]
|
|
959
|
+
if dep_flag.nil?
|
|
960
|
+
# Missing flag dependency - cannot evaluate locally
|
|
961
|
+
evaluation_cache[dep_flag_key] = nil
|
|
962
|
+
raise InconclusiveMatchError,
|
|
963
|
+
"Cannot evaluate flag dependency '#{dep_flag_key}' - flag not found in local flags"
|
|
964
|
+
elsif !dep_flag[:active]
|
|
965
|
+
# Check if the flag is active (same check as in _compute_flag_locally)
|
|
966
|
+
evaluation_cache[dep_flag_key] = false
|
|
967
|
+
else
|
|
968
|
+
# Recursively evaluate the dependency using existing instance method
|
|
969
|
+
begin
|
|
970
|
+
dep_result = match_feature_flag_properties(
|
|
971
|
+
dep_flag,
|
|
972
|
+
distinct_id,
|
|
973
|
+
properties,
|
|
974
|
+
evaluation_cache,
|
|
975
|
+
cohort_properties,
|
|
976
|
+
snapshot: snapshot
|
|
977
|
+
)
|
|
978
|
+
evaluation_cache[dep_flag_key] = dep_result
|
|
979
|
+
rescue InconclusiveMatchError => e
|
|
980
|
+
# If we can't evaluate a dependency, store nil and propagate the error
|
|
981
|
+
evaluation_cache[dep_flag_key] = nil
|
|
982
|
+
raise InconclusiveMatchError,
|
|
983
|
+
"Cannot evaluate flag dependency '#{dep_flag_key}': #{e.message}"
|
|
984
|
+
end
|
|
985
|
+
end
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
# Check the cached result
|
|
989
|
+
cached_result = evaluation_cache[dep_flag_key]
|
|
990
|
+
if cached_result.nil?
|
|
991
|
+
# Previously inconclusive - raise error again
|
|
992
|
+
raise InconclusiveMatchError,
|
|
993
|
+
"Flag dependency '#{dep_flag_key}' was previously inconclusive"
|
|
994
|
+
elsif !cached_result
|
|
995
|
+
# Definitive False result - dependency failed
|
|
996
|
+
return false
|
|
997
|
+
end
|
|
998
|
+
end
|
|
999
|
+
|
|
1000
|
+
# Get the expected value of the immediate dependency and the actual value
|
|
1001
|
+
expected_value = property[:value]
|
|
1002
|
+
# The flag we want to evaluate is defined by :key which should ALSO be the last key in the dependency chain
|
|
1003
|
+
actual_value = evaluation_cache[property[:key]]
|
|
1004
|
+
|
|
1005
|
+
self.class.matches_dependency_value(expected_value, actual_value)
|
|
1006
|
+
end
|
|
1007
|
+
|
|
1008
|
+
def self.matches_dependency_value(expected_value, actual_value)
|
|
1009
|
+
# Check if the actual flag value matches the expected dependency value.
|
|
1010
|
+
#
|
|
1011
|
+
# - String variant case: check for exact match or boolean true
|
|
1012
|
+
# - Boolean case: must match expected boolean value
|
|
1013
|
+
#
|
|
1014
|
+
# @param expected_value [Object] The expected value from the property
|
|
1015
|
+
# @param actual_value [Object] The actual value returned by the flag evaluation
|
|
1016
|
+
# @return [Boolean] True if the values match according to flag dependency rules
|
|
1017
|
+
|
|
1018
|
+
# String variant case - check for exact match or boolean true
|
|
1019
|
+
if actual_value.is_a?(String) && !actual_value.empty?
|
|
1020
|
+
if expected_value.is_a?(TrueClass) || expected_value.is_a?(FalseClass)
|
|
1021
|
+
# Any variant matches boolean true
|
|
1022
|
+
return expected_value
|
|
1023
|
+
elsif expected_value.is_a?(String)
|
|
1024
|
+
# variants are case-sensitive, hence our comparison is too
|
|
1025
|
+
return actual_value == expected_value
|
|
1026
|
+
else
|
|
1027
|
+
return false
|
|
1028
|
+
end
|
|
1029
|
+
|
|
1030
|
+
# Boolean case - must match expected boolean value
|
|
1031
|
+
elsif actual_value.is_a?(TrueClass) || actual_value.is_a?(FalseClass)
|
|
1032
|
+
return actual_value == expected_value if expected_value.is_a?(TrueClass) || expected_value.is_a?(FalseClass)
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
# Default case
|
|
1036
|
+
false
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
private_class_method :extract_value, :find_cohort_property, :nested_property_group?,
|
|
1040
|
+
:match_nested_property_group, :match_regular_property_group
|
|
1041
|
+
|
|
1042
|
+
# Publish/capture all matching state together so a poll cannot switch semantics
|
|
1043
|
+
# (or dependency/cohort definitions) halfway through one local evaluation.
|
|
1044
|
+
def _evaluation_snapshot
|
|
1045
|
+
@definition_snapshot.value
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}, group_properties = {},
|
|
1049
|
+
snapshot: _evaluation_snapshot)
|
|
1050
|
+
raise RequiresServerEvaluation, 'Flag has experience continuity enabled' if flag[:ensure_experience_continuity]
|
|
1051
|
+
|
|
1052
|
+
return false unless flag[:active]
|
|
1053
|
+
|
|
1054
|
+
# Create evaluation cache for flag dependencies
|
|
1055
|
+
evaluation_cache = {}
|
|
1056
|
+
|
|
1057
|
+
flag_filters = flag[:filters] || {}
|
|
1058
|
+
|
|
1059
|
+
aggregation_group_type_index = flag_filters[:aggregation_group_type_index]
|
|
1060
|
+
if aggregation_group_type_index.nil?
|
|
1061
|
+
local_person_properties = person_properties || {}
|
|
1062
|
+
unless local_person_properties.key?(:distinct_id) || local_person_properties.key?('distinct_id')
|
|
1063
|
+
local_person_properties = local_person_properties.merge(distinct_id: distinct_id)
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
return match_feature_flag_properties(flag, distinct_id, local_person_properties, evaluation_cache,
|
|
1067
|
+
snapshot[:cohorts], groups: groups, group_properties: group_properties,
|
|
1068
|
+
snapshot: snapshot)
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
group_name = snapshot[:group_type_mapping][aggregation_group_type_index.to_s.to_sym]
|
|
1072
|
+
|
|
1073
|
+
if group_name.nil?
|
|
1074
|
+
logger.warn(
|
|
1075
|
+
"[FEATURE FLAGS] Unknown group type index #{aggregation_group_type_index} for feature flag #{flag[:key]}"
|
|
1076
|
+
)
|
|
1077
|
+
# failover to `/flags/`
|
|
1078
|
+
raise InconclusiveMatchError, 'Flag has unknown group type index'
|
|
1079
|
+
end
|
|
1080
|
+
|
|
1081
|
+
group_name_symbol = group_name.to_sym
|
|
1082
|
+
|
|
1083
|
+
unless groups.key?(group_name_symbol)
|
|
1084
|
+
# Group flags are never enabled if appropriate `groups` aren't passed in
|
|
1085
|
+
# don't failover to `/flags/`, since response will be the same
|
|
1086
|
+
logger.warn "[FEATURE FLAGS] Can't compute group feature flag: #{flag[:key]} without group names passed in"
|
|
1087
|
+
return false
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
focused_group_properties = group_properties[group_name_symbol]
|
|
1091
|
+
match_feature_flag_properties(flag, groups[group_name_symbol], focused_group_properties, evaluation_cache,
|
|
1092
|
+
snapshot[:cohorts], groups: groups, group_properties: group_properties,
|
|
1093
|
+
snapshot: snapshot)
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
def _compute_flag_payload_locally(key, match_value, snapshot: _evaluation_snapshot)
|
|
1097
|
+
return nil if snapshot[:flags_by_key].nil?
|
|
1098
|
+
|
|
1099
|
+
key = key.to_s
|
|
1100
|
+
response = nil
|
|
1101
|
+
if [true, false].include? match_value
|
|
1102
|
+
response = snapshot[:flags_by_key].dig(key, :filters, :payloads, match_value.to_s.to_sym)
|
|
1103
|
+
elsif match_value.is_a? String
|
|
1104
|
+
response = snapshot[:flags_by_key].dig(key, :filters, :payloads, match_value.to_sym)
|
|
1105
|
+
end
|
|
1106
|
+
response
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cache, cohort_properties = {},
|
|
1110
|
+
groups: {}, group_properties: {}, snapshot: _evaluation_snapshot)
|
|
1111
|
+
flag_filters = flag[:filters] || {}
|
|
1112
|
+
|
|
1113
|
+
flag_conditions = flag_filters[:groups] || []
|
|
1114
|
+
flag_aggregation = flag_filters[:aggregation_group_type_index]
|
|
1115
|
+
early_exit = flag_filters[:early_exit] == true
|
|
1116
|
+
is_inconclusive = false
|
|
1117
|
+
result = nil
|
|
1118
|
+
|
|
1119
|
+
# NOTE: This NEEDS to be `each` because `each_key` breaks
|
|
1120
|
+
flag_conditions.each do |condition|
|
|
1121
|
+
# Per-condition aggregation overrides only when the condition explicitly
|
|
1122
|
+
# sets its own aggregation_group_type_index (mixed targeting). When absent,
|
|
1123
|
+
# use the properties/bucketing already resolved by the caller.
|
|
1124
|
+
condition_aggregation = condition.fetch(:aggregation_group_type_index, flag_aggregation)
|
|
1125
|
+
|
|
1126
|
+
effective_properties = properties
|
|
1127
|
+
effective_bucketing = distinct_id
|
|
1128
|
+
|
|
1129
|
+
# Mixed-override path: condition-level aggregation differs from flag-level.
|
|
1130
|
+
# This assumes flag-level aggregation is nil for mixed flags.
|
|
1131
|
+
if condition_aggregation != flag_aggregation
|
|
1132
|
+
if condition_aggregation.nil?
|
|
1133
|
+
# Person condition under a mixed flag — caller already passed person props/bucketing.
|
|
1134
|
+
else
|
|
1135
|
+
group_name = snapshot[:group_type_mapping][condition_aggregation.to_s.to_sym]
|
|
1136
|
+
if group_name.nil? || !groups.key?(group_name.to_sym)
|
|
1137
|
+
logger.debug do
|
|
1138
|
+
"[FEATURE FLAGS] Skipping group condition for flag '#{flag[:key]}': " \
|
|
1139
|
+
"group type index #{condition_aggregation} not available"
|
|
1140
|
+
end
|
|
1141
|
+
next
|
|
1142
|
+
end
|
|
1143
|
+
unless group_properties.key?(group_name.to_sym)
|
|
1144
|
+
is_inconclusive = true
|
|
1145
|
+
next
|
|
1146
|
+
end
|
|
1147
|
+
effective_properties = group_properties[group_name.to_sym]
|
|
1148
|
+
effective_bucketing = groups[group_name.to_sym]
|
|
1149
|
+
end
|
|
1150
|
+
end
|
|
1151
|
+
|
|
1152
|
+
case condition_match_outcome(flag, effective_bucketing, condition, effective_properties, evaluation_cache,
|
|
1153
|
+
cohort_properties, snapshot: snapshot)
|
|
1154
|
+
when :match
|
|
1155
|
+
variant_override = condition[:variant]
|
|
1156
|
+
flag_multivariate = flag_filters[:multivariate] || {}
|
|
1157
|
+
flag_variants = flag_multivariate[:variants] || []
|
|
1158
|
+
variant = if flag_variants.map { |variant| variant[:key] }.include?(condition[:variant])
|
|
1159
|
+
variant_override
|
|
1160
|
+
else
|
|
1161
|
+
get_matching_variant(flag, effective_bucketing)
|
|
1162
|
+
end
|
|
1163
|
+
result = variant || true
|
|
1164
|
+
break
|
|
1165
|
+
when :out_of_rollout_bound
|
|
1166
|
+
break if early_exit
|
|
1167
|
+
end
|
|
1168
|
+
rescue RequiresServerEvaluation
|
|
1169
|
+
# Static cohort or other missing server-side data - must fallback to API
|
|
1170
|
+
raise
|
|
1171
|
+
rescue InconclusiveMatchError
|
|
1172
|
+
# Evaluation error (bad regex, invalid date, missing property, etc.)
|
|
1173
|
+
# Track that we had an inconclusive match, but try other conditions
|
|
1174
|
+
is_inconclusive = true
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
if !result.nil?
|
|
1178
|
+
return result
|
|
1179
|
+
elsif is_inconclusive
|
|
1180
|
+
raise InconclusiveMatchError, "Can't determine if feature flag is enabled or not with given properties"
|
|
1181
|
+
end
|
|
1182
|
+
|
|
1183
|
+
# We can only return False when all conditions are False
|
|
1184
|
+
false
|
|
1185
|
+
end
|
|
1186
|
+
|
|
1187
|
+
def condition_match(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties = {},
|
|
1188
|
+
snapshot: _evaluation_snapshot)
|
|
1189
|
+
condition_match_outcome(flag, distinct_id, condition, properties, evaluation_cache,
|
|
1190
|
+
cohort_properties, snapshot: snapshot) == :match
|
|
1191
|
+
end
|
|
1192
|
+
|
|
1193
|
+
# Evaluates a single condition group and returns a tri-state outcome:
|
|
1194
|
+
# :match - property filters matched AND the rollout included the user
|
|
1195
|
+
# :no_match - a property filter did NOT match
|
|
1196
|
+
# :out_of_rollout_bound - property filters matched (or there were none) but the
|
|
1197
|
+
# rollout percentage excluded the user
|
|
1198
|
+
# Distinguishing :no_match from :out_of_rollout_bound lets the caller implement the
|
|
1199
|
+
# early_exit behavior (mirrors the server-side Rust evaluation engine).
|
|
1200
|
+
def condition_match_outcome(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties = {},
|
|
1201
|
+
snapshot: _evaluation_snapshot)
|
|
1202
|
+
rollout_percentage = condition[:rollout_percentage]
|
|
1203
|
+
|
|
1204
|
+
unless (condition[:properties] || []).empty?
|
|
1205
|
+
unless condition[:properties].all? do |prop|
|
|
1206
|
+
if prop[:type] == 'flag'
|
|
1207
|
+
evaluate_flag_dependency(prop, evaluation_cache, distinct_id, properties, cohort_properties,
|
|
1208
|
+
snapshot: snapshot)
|
|
1209
|
+
else
|
|
1210
|
+
FeatureFlagsPoller.match_property(prop, properties, cohort_properties,
|
|
1211
|
+
property_matching_version: snapshot[:property_matching_version])
|
|
1212
|
+
end
|
|
1213
|
+
end
|
|
1214
|
+
return :no_match
|
|
1215
|
+
end
|
|
1216
|
+
|
|
1217
|
+
return :match if rollout_percentage.nil?
|
|
1218
|
+
end
|
|
1219
|
+
|
|
1220
|
+
if !rollout_percentage.nil? && (_hash(flag[:key], distinct_id) > (rollout_percentage.to_f / 100))
|
|
1221
|
+
return :out_of_rollout_bound
|
|
1222
|
+
end
|
|
1223
|
+
|
|
1224
|
+
:match
|
|
1225
|
+
end
|
|
1226
|
+
|
|
1227
|
+
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
|
|
1228
|
+
# Given the same distinct_id and key, it'll always return the same float. These floats are
|
|
1229
|
+
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
|
|
1230
|
+
# we can do _hash(key, distinct_id) < 0.2
|
|
1231
|
+
def _hash(key, distinct_id, salt = '')
|
|
1232
|
+
hash_key = Digest::SHA1.hexdigest "#{key}.#{distinct_id}#{salt}"
|
|
1233
|
+
(Integer(hash_key[0..14], 16).to_f / 0xfffffffffffffff)
|
|
1234
|
+
end
|
|
1235
|
+
|
|
1236
|
+
def get_matching_variant(flag, distinct_id)
|
|
1237
|
+
hash_value = _hash(flag[:key], distinct_id, 'variant')
|
|
1238
|
+
matching_variant = variant_lookup_table(flag).find do |variant|
|
|
1239
|
+
hash_value >= variant[:value_min] and hash_value < variant[:value_max]
|
|
1240
|
+
end
|
|
1241
|
+
matching_variant.nil? ? nil : matching_variant[:key]
|
|
1242
|
+
end
|
|
1243
|
+
|
|
1244
|
+
def variant_lookup_table(flag)
|
|
1245
|
+
lookup_table = []
|
|
1246
|
+
value_min = 0
|
|
1247
|
+
flag_filters = flag[:filters] || {}
|
|
1248
|
+
variants = flag_filters[:multivariate] || {}
|
|
1249
|
+
multivariates = variants[:variants] || []
|
|
1250
|
+
multivariates.each do |variant|
|
|
1251
|
+
value_max = value_min + (variant[:rollout_percentage].to_f / 100)
|
|
1252
|
+
lookup_table << { value_min: value_min, value_max: value_max, key: variant[:key] }
|
|
1253
|
+
value_min = value_max
|
|
1254
|
+
end
|
|
1255
|
+
lookup_table
|
|
1256
|
+
end
|
|
1257
|
+
|
|
1258
|
+
def _load_feature_flags
|
|
1259
|
+
should_fetch = true
|
|
1260
|
+
|
|
1261
|
+
if @flag_definition_cache_provider
|
|
1262
|
+
begin
|
|
1263
|
+
should_fetch = @flag_definition_cache_provider.should_fetch_flag_definitions?
|
|
1264
|
+
rescue StandardError => e
|
|
1265
|
+
logger.error("[FEATURE FLAGS] Cache provider should_fetch error: #{e}")
|
|
1266
|
+
should_fetch = true
|
|
1267
|
+
end
|
|
1268
|
+
end
|
|
1269
|
+
|
|
1270
|
+
unless should_fetch
|
|
1271
|
+
begin
|
|
1272
|
+
cached_data = @flag_definition_cache_provider.flag_definitions
|
|
1273
|
+
if cached_data
|
|
1274
|
+
logger.debug '[FEATURE FLAGS] Using cached flag definitions from external cache'
|
|
1275
|
+
_apply_flag_definitions(cached_data)
|
|
1276
|
+
return
|
|
1277
|
+
elsif @feature_flags.empty?
|
|
1278
|
+
# Emergency fallback: cache empty and no flags loaded -> fetch from API
|
|
1279
|
+
should_fetch = true
|
|
1280
|
+
end
|
|
1281
|
+
rescue StandardError => e
|
|
1282
|
+
logger.error("[FEATURE FLAGS] Cache provider get error: #{e}")
|
|
1283
|
+
should_fetch = true
|
|
1284
|
+
end
|
|
1285
|
+
end
|
|
1286
|
+
|
|
1287
|
+
_fetch_and_apply_flag_definitions if should_fetch
|
|
1288
|
+
end
|
|
1289
|
+
|
|
1290
|
+
def _fetch_and_apply_flag_definitions
|
|
1291
|
+
begin
|
|
1292
|
+
res = _request_feature_flag_definitions(etag: @flags_etag.value)
|
|
1293
|
+
rescue StandardError => e
|
|
1294
|
+
@on_error.call(-1, e.to_s)
|
|
1295
|
+
return
|
|
1296
|
+
end
|
|
1297
|
+
|
|
1298
|
+
# Handle 304 Not Modified - flags haven't changed, skip processing
|
|
1299
|
+
# Only update ETag if the 304 response includes one
|
|
1300
|
+
if res[:not_modified]
|
|
1301
|
+
@flags_etag.value = res[:etag] if res[:etag]
|
|
1302
|
+
logger.debug '[FEATURE FLAGS] Flags not modified (304), using cached data'
|
|
1303
|
+
return
|
|
1304
|
+
end
|
|
1305
|
+
|
|
1306
|
+
# Handle quota limits with 402 status
|
|
1307
|
+
if res.is_a?(Hash) && res[:status] == 402
|
|
1308
|
+
logger.warn(
|
|
1309
|
+
'[FEATURE FLAGS] Feature flags quota limit exceeded - unsetting all local flags. ' \
|
|
1310
|
+
'Learn more about billing limits at https://txlemetry.com/docs/billing/limits-alerts'
|
|
1311
|
+
)
|
|
1312
|
+
@flag_definitions_update_mutex.synchronize do
|
|
1313
|
+
definitions_were_loaded = definitions_loaded?
|
|
1314
|
+
@feature_flags = Concurrent::Array.new
|
|
1315
|
+
@feature_flags_by_key = {}
|
|
1316
|
+
@group_type_mapping = Concurrent::Hash.new
|
|
1317
|
+
@cohorts = Concurrent::Hash.new
|
|
1318
|
+
@flag_definitions_loaded_at.value = nil
|
|
1319
|
+
@minimal_flag_called_events = false
|
|
1320
|
+
@definition_snapshot.value = {
|
|
1321
|
+
flags: @feature_flags, flags_by_key: @feature_flags_by_key,
|
|
1322
|
+
group_type_mapping: @group_type_mapping, cohorts: @cohorts,
|
|
1323
|
+
minimal_flag_called_events: false, property_matching_version: 1
|
|
1324
|
+
}.freeze
|
|
1325
|
+
@loaded_flags_successfully_once.make_false
|
|
1326
|
+
@quota_limited.make_true
|
|
1327
|
+
@on_flag_definitions_updated&.call if definitions_were_loaded
|
|
1328
|
+
end
|
|
1329
|
+
return
|
|
1330
|
+
end
|
|
1331
|
+
|
|
1332
|
+
if res.key?(:flags)
|
|
1333
|
+
# Only update ETag on successful responses with flag data
|
|
1334
|
+
@flags_etag.value = res[:etag]
|
|
1335
|
+
|
|
1336
|
+
_apply_flag_definitions(res)
|
|
1337
|
+
_store_in_cache_provider
|
|
1338
|
+
else
|
|
1339
|
+
logger.debug "Failed to load feature flags: #{res}"
|
|
1340
|
+
end
|
|
1341
|
+
end
|
|
1342
|
+
|
|
1343
|
+
def _store_in_cache_provider
|
|
1344
|
+
return unless @flag_definition_cache_provider
|
|
1345
|
+
|
|
1346
|
+
begin
|
|
1347
|
+
snapshot = _evaluation_snapshot
|
|
1348
|
+
data = {
|
|
1349
|
+
flags: snapshot[:flags].to_a,
|
|
1350
|
+
group_type_mapping: snapshot[:group_type_mapping].to_h,
|
|
1351
|
+
cohorts: snapshot[:cohorts].to_h,
|
|
1352
|
+
minimal_flag_called_events: snapshot[:minimal_flag_called_events],
|
|
1353
|
+
property_matching_version: snapshot[:property_matching_version]
|
|
1354
|
+
}
|
|
1355
|
+
@flag_definition_cache_provider.on_flag_definitions_received(data)
|
|
1356
|
+
rescue StandardError => e
|
|
1357
|
+
logger.error("[FEATURE FLAGS] Cache provider store error: #{e}")
|
|
1358
|
+
end
|
|
1359
|
+
end
|
|
1360
|
+
|
|
1361
|
+
def _apply_flag_definitions(data)
|
|
1362
|
+
flags = get_by_symbol_or_string_key(data, 'flags') || []
|
|
1363
|
+
group_type_mapping = get_by_symbol_or_string_key(data, 'group_type_mapping') || {}
|
|
1364
|
+
cohorts = get_by_symbol_or_string_key(data, 'cohorts') || {}
|
|
1365
|
+
minimal_flag_called_events = get_by_symbol_or_string_key(data, 'minimal_flag_called_events')
|
|
1366
|
+
|
|
1367
|
+
property_matching_version = get_by_symbol_or_string_key(data, 'property_matching_version') || 1
|
|
1368
|
+
new_flags = Concurrent::Array.new(flags.map { |f| deep_symbolize_keys(f) })
|
|
1369
|
+
new_by_key = {}
|
|
1370
|
+
new_flags.each do |flag|
|
|
1371
|
+
new_by_key[flag[:key]] = flag unless flag[:key].nil?
|
|
1372
|
+
end
|
|
1373
|
+
new_group_type_mapping = Concurrent::Hash[deep_symbolize_keys(group_type_mapping)]
|
|
1374
|
+
new_cohorts = Concurrent::Hash[deep_symbolize_keys(cohorts)]
|
|
1375
|
+
|
|
1376
|
+
# A read of the new definitions must not record its event before the tracker reset.
|
|
1377
|
+
@flag_definitions_update_mutex.synchronize do
|
|
1378
|
+
@definition_snapshot.value = {
|
|
1379
|
+
flags: new_flags, flags_by_key: new_by_key,
|
|
1380
|
+
group_type_mapping: new_group_type_mapping, cohorts: new_cohorts,
|
|
1381
|
+
minimal_flag_called_events: minimal_flag_called_events == true,
|
|
1382
|
+
property_matching_version: property_matching_version
|
|
1383
|
+
}.freeze
|
|
1384
|
+
@feature_flags = new_flags
|
|
1385
|
+
@feature_flags_by_key = new_by_key
|
|
1386
|
+
@group_type_mapping = new_group_type_mapping
|
|
1387
|
+
@cohorts = new_cohorts
|
|
1388
|
+
@minimal_flag_called_events = minimal_flag_called_events == true
|
|
1389
|
+
@flag_definitions_loaded_at.value = (Time.now.to_f * 1000).to_i
|
|
1390
|
+
@loaded_flags_successfully_once.make_true if @loaded_flags_successfully_once.false?
|
|
1391
|
+
@on_flag_definitions_updated&.call
|
|
1392
|
+
end
|
|
1393
|
+
logger.debug "Loaded #{new_flags.length} feature flags and #{new_cohorts.length} cohorts"
|
|
1394
|
+
end
|
|
1395
|
+
|
|
1396
|
+
def _request_feature_flag_definitions(etag: nil)
|
|
1397
|
+
uri = URI("#{@host}/flags/definitions")
|
|
1398
|
+
uri.query = URI.encode_www_form([['token', @project_api_key], %w[send_cohorts true]])
|
|
1399
|
+
req = Net::HTTP::Get.new(uri)
|
|
1400
|
+
req['Authorization'] = "Bearer #{@secret_key}"
|
|
1401
|
+
req['If-None-Match'] = etag if etag
|
|
1402
|
+
|
|
1403
|
+
_request(uri, req, nil, include_etag: true)
|
|
1404
|
+
end
|
|
1405
|
+
|
|
1406
|
+
def _request_feature_flag_evaluation(data = {})
|
|
1407
|
+
uri = URI("#{@host}/flags/?v=2")
|
|
1408
|
+
req = Net::HTTP::Post.new(uri)
|
|
1409
|
+
req['Content-Type'] = 'application/json'
|
|
1410
|
+
data['token'] = @project_api_key
|
|
1411
|
+
req.body = data.to_json
|
|
1412
|
+
|
|
1413
|
+
_request(
|
|
1414
|
+
uri,
|
|
1415
|
+
req,
|
|
1416
|
+
@feature_flag_request_timeout_seconds,
|
|
1417
|
+
retry_status_codes: RETRYABLE_FLAGS_REQUEST_STATUS_CODES
|
|
1418
|
+
)
|
|
1419
|
+
end
|
|
1420
|
+
|
|
1421
|
+
def _request_remote_config_payload(flag_key)
|
|
1422
|
+
uri = URI("#{@host}/api/projects/@current/feature_flags/#{flag_key}/remote_config")
|
|
1423
|
+
uri.query = URI.encode_www_form([['token', @project_api_key]])
|
|
1424
|
+
req = Net::HTTP::Get.new(uri)
|
|
1425
|
+
req['Content-Type'] = 'application/json'
|
|
1426
|
+
req['Authorization'] = "Bearer #{@secret_key}"
|
|
1427
|
+
|
|
1428
|
+
_request(uri, req, @feature_flag_request_timeout_seconds)
|
|
1429
|
+
end
|
|
1430
|
+
|
|
1431
|
+
# Transient network errors that are safe to retry. Flag requests are
|
|
1432
|
+
# retry-safe (stateless reads and evaluations, no server-side mutation), so
|
|
1433
|
+
# a one-off blip (TCP retransmit, TLS jitter, an edge/proxy hiccup) should
|
|
1434
|
+
# be absorbed by a retry rather than surfaced to the caller.
|
|
1435
|
+
RETRYABLE_REQUEST_ERRORS = [
|
|
1436
|
+
Timeout::Error, # covers Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
|
|
1437
|
+
Errno::ECONNRESET,
|
|
1438
|
+
EOFError
|
|
1439
|
+
].freeze
|
|
1440
|
+
RETRYABLE_FLAGS_REQUEST_STATUS_CODES = [502, 504].freeze
|
|
1441
|
+
|
|
1442
|
+
def _request(uri, request_object, timeout = nil, include_etag: false, retry_status_codes: [])
|
|
1443
|
+
request_object['User-Agent'] = @user_agent
|
|
1444
|
+
request_timeout = timeout || 10
|
|
1445
|
+
backoff_policy = nil
|
|
1446
|
+
attempts = 0
|
|
1447
|
+
|
|
1448
|
+
loop do
|
|
1449
|
+
attempts += 1
|
|
1450
|
+
Net::HTTP.start(
|
|
1451
|
+
uri.hostname,
|
|
1452
|
+
uri.port,
|
|
1453
|
+
use_ssl: uri.scheme == 'https',
|
|
1454
|
+
open_timeout: request_timeout,
|
|
1455
|
+
read_timeout: request_timeout,
|
|
1456
|
+
write_timeout: request_timeout
|
|
1457
|
+
) do |http|
|
|
1458
|
+
res = http.request(request_object)
|
|
1459
|
+
status_code = res.code.to_i
|
|
1460
|
+
etag = include_etag ? res['ETag'] : nil
|
|
1461
|
+
|
|
1462
|
+
if retry_status_codes.include?(status_code) && attempts <= @feature_flag_request_max_retries
|
|
1463
|
+
backoff_policy ||= BackoffPolicy.new
|
|
1464
|
+
interval = backoff_policy.next_interval.to_f / 1000
|
|
1465
|
+
logger.debug(
|
|
1466
|
+
"Retrying request to #{_mask_tokens_in_url(uri.to_s)} after HTTP #{status_code} (attempt #{attempts})"
|
|
1467
|
+
)
|
|
1468
|
+
sleep(interval)
|
|
1469
|
+
next
|
|
1470
|
+
end
|
|
1471
|
+
|
|
1472
|
+
# Handle 304 Not Modified - return special response indicating no change
|
|
1473
|
+
if status_code == 304
|
|
1474
|
+
logger.debug("#{request_object.method} #{_mask_tokens_in_url(uri.to_s)} returned 304 Not Modified")
|
|
1475
|
+
return { not_modified: true, etag: etag, status: status_code }
|
|
1476
|
+
end
|
|
1477
|
+
|
|
1478
|
+
# Parse response body to hash
|
|
1479
|
+
begin
|
|
1480
|
+
response = JSON.parse(res.body, symbolize_names: true)
|
|
1481
|
+
# Only add status (and etag if requested) if response is a hash
|
|
1482
|
+
extra_fields = { status: status_code }
|
|
1483
|
+
extra_fields[:etag] = etag if include_etag
|
|
1484
|
+
response = response.merge(extra_fields) if response.is_a?(Hash)
|
|
1485
|
+
return response
|
|
1486
|
+
rescue JSON::ParserError
|
|
1487
|
+
# Handle case when response isn't valid JSON
|
|
1488
|
+
error_response = { error: 'Invalid JSON response', body: res.body, status: status_code }
|
|
1489
|
+
error_response[:etag] = etag if include_etag
|
|
1490
|
+
return error_response
|
|
1491
|
+
end
|
|
1492
|
+
end
|
|
1493
|
+
rescue *RETRYABLE_REQUEST_ERRORS => e
|
|
1494
|
+
if attempts <= @feature_flag_request_max_retries
|
|
1495
|
+
backoff_policy ||= BackoffPolicy.new
|
|
1496
|
+
interval = backoff_policy.next_interval.to_f / 1000
|
|
1497
|
+
logger.debug("Retrying request to #{_mask_tokens_in_url(uri.to_s)} after #{e.class} (attempt #{attempts})")
|
|
1498
|
+
sleep(interval)
|
|
1499
|
+
next
|
|
1500
|
+
end
|
|
1501
|
+
logger.debug("Unable to complete request to #{_mask_tokens_in_url(uri.to_s)}")
|
|
1502
|
+
raise
|
|
1503
|
+
rescue Errno::EINVAL,
|
|
1504
|
+
Net::HTTPBadResponse,
|
|
1505
|
+
Net::HTTPHeaderSyntaxError,
|
|
1506
|
+
Net::ProtocolError
|
|
1507
|
+
logger.debug("Unable to complete request to #{_mask_tokens_in_url(uri.to_s)}")
|
|
1508
|
+
raise
|
|
1509
|
+
end
|
|
1510
|
+
end
|
|
1511
|
+
|
|
1512
|
+
def _mask_tokens_in_url(url)
|
|
1513
|
+
url.gsub(/token=([^&]{10})[^&]*/, 'token=\1...')
|
|
1514
|
+
end
|
|
1515
|
+
end
|
|
1516
|
+
end
|