optimizely-sdk 4.0.1 → 5.0.0.pre.beta

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.
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright 2022, Optimizely and contributors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+
19
+ require 'json'
20
+ require_relative '../helpers/constants'
21
+
22
+ module Optimizely
23
+ class OdpEvent
24
+ # Representation of an odp event which can be sent to the Optimizely odp platform.
25
+
26
+ KEY_FOR_USER_ID = Helpers::Constants::ODP_MANAGER_CONFIG[:KEY_FOR_USER_ID]
27
+
28
+ def initialize(type:, action:, identifiers:, data:)
29
+ @type = type
30
+ @action = action
31
+ @identifiers = convert_identifiers(identifiers)
32
+ @data = add_common_event_data(data)
33
+ end
34
+
35
+ def add_common_event_data(custom_data)
36
+ data = {
37
+ idempotence_id: SecureRandom.uuid,
38
+ data_source_type: 'sdk',
39
+ data_source: 'ruby-sdk',
40
+ data_source_version: VERSION
41
+ }
42
+ data.update(custom_data)
43
+ data
44
+ end
45
+
46
+ def convert_identifiers(identifiers)
47
+ # Convert incorrect case/separator of identifier key `fs_user_id`
48
+ # (ie. `fs-user-id`, `FS_USER_ID`).
49
+
50
+ identifiers.clone.each_key do |key|
51
+ break if key == KEY_FOR_USER_ID
52
+
53
+ if ['fs-user-id', KEY_FOR_USER_ID].include?(key.downcase)
54
+ identifiers[KEY_FOR_USER_ID] = identifiers.delete(key)
55
+ break
56
+ end
57
+ end
58
+
59
+ identifiers
60
+ end
61
+
62
+ def to_json(*_args)
63
+ {
64
+ type: @type,
65
+ action: @action,
66
+ identifiers: @identifiers,
67
+ data: @data
68
+ }.to_json
69
+ end
70
+
71
+ def ==(other)
72
+ to_json == other.to_json
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright 2022, Optimizely and contributors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+
19
+ require 'json'
20
+
21
+ module Optimizely
22
+ class OdpEventApiManager
23
+ # Interface that handles sending ODP events.
24
+
25
+ def initialize(logger: nil, proxy_config: nil, timeout: nil)
26
+ @logger = logger || NoOpLogger.new
27
+ @proxy_config = proxy_config
28
+ @timeout = timeout || Optimizely::Helpers::Constants::ODP_REST_API_CONFIG[:REQUEST_TIMEOUT]
29
+ end
30
+
31
+ # Send events to the ODP Events API.
32
+ #
33
+ # @param api_key - public api key
34
+ # @param api_host - domain url of the host
35
+ # @param events - array of events to send
36
+
37
+ def send_odp_events(api_key, api_host, events)
38
+ should_retry = false
39
+ url = "#{api_host}/v3/events"
40
+
41
+ headers = {'Content-Type' => 'application/json', 'x-api-key' => api_key.to_s}
42
+
43
+ begin
44
+ response = Helpers::HttpUtils.make_request(
45
+ url, :post, events.to_json, headers, @timeout, @proxy_config
46
+ )
47
+ rescue SocketError, Timeout::Error, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::EFAULT, Errno::ENETUNREACH, Errno::ENETDOWN, Errno::ECONNREFUSED
48
+ log_failure('network error')
49
+ should_retry = true
50
+ return should_retry
51
+ rescue StandardError => e
52
+ log_failure(e)
53
+ return should_retry
54
+ end
55
+
56
+ status = response.code.to_i
57
+ if status >= 400
58
+ log_failure(!response.body.empty? ? response.body : "#{status}: #{response.message}")
59
+ should_retry = status >= 500
60
+ end
61
+ should_retry
62
+ end
63
+
64
+ private
65
+
66
+ def log_failure(message, level = Logger::ERROR)
67
+ @logger.log(level, format(Optimizely::Helpers::Constants::ODP_LOGS[:ODP_EVENT_FAILED], message))
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright 2019, 2022, Optimizely and contributors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+ require_relative 'odp_event_api_manager'
19
+ require_relative '../helpers/constants'
20
+ require_relative 'odp_event'
21
+
22
+ module Optimizely
23
+ class OdpEventManager
24
+ # Events passed to the OdpEventManager are immediately added to an EventQueue.
25
+ # The OdpEventManager maintains a single consumer thread that pulls events off of
26
+ # the BlockingQueue and buffers them for either a configured batch size or for a
27
+ # maximum duration before the resulting OdpEvent is sent to Odp.
28
+
29
+ attr_reader :batch_size, :api_manager, :logger
30
+ attr_accessor :odp_config
31
+
32
+ def initialize(
33
+ api_manager: nil,
34
+ logger: NoOpLogger.new,
35
+ proxy_config: nil,
36
+ request_timeout: nil,
37
+ flush_interval: nil
38
+ )
39
+ @odp_config = nil
40
+ @api_host = nil
41
+ @api_key = nil
42
+
43
+ @mutex = Mutex.new
44
+ @event_queue = SizedQueue.new(Optimizely::Helpers::Constants::ODP_EVENT_MANAGER[:DEFAULT_QUEUE_CAPACITY])
45
+ @queue_capacity = Helpers::Constants::ODP_EVENT_MANAGER[:DEFAULT_QUEUE_CAPACITY]
46
+ # received signal should be sent after adding item to event_queue
47
+ @received = ConditionVariable.new
48
+ @logger = logger
49
+ @api_manager = api_manager || OdpEventApiManager.new(logger: @logger, proxy_config: proxy_config, timeout: request_timeout)
50
+ @flush_interval = flush_interval || Helpers::Constants::ODP_EVENT_MANAGER[:DEFAULT_FLUSH_INTERVAL_SECONDS]
51
+ @batch_size = @flush_interval&.zero? ? 1 : Helpers::Constants::ODP_EVENT_MANAGER[:DEFAULT_BATCH_SIZE]
52
+ @flush_deadline = 0
53
+ @retry_count = Helpers::Constants::ODP_EVENT_MANAGER[:DEFAULT_RETRY_COUNT]
54
+ # current_batch should only be accessed by processing thread
55
+ @current_batch = []
56
+ @thread = nil
57
+ @thread_exception = false
58
+ end
59
+
60
+ def start!(odp_config)
61
+ if running?
62
+ @logger.log(Logger::WARN, 'Service already started.')
63
+ return
64
+ end
65
+
66
+ @odp_config = odp_config
67
+ @api_host = odp_config.api_host
68
+ @api_key = odp_config.api_key
69
+
70
+ @thread = Thread.new { run }
71
+ @logger.log(Logger::INFO, 'Starting scheduler.')
72
+ end
73
+
74
+ def flush
75
+ begin
76
+ @event_queue.push(:FLUSH_SIGNAL, true)
77
+ rescue ThreadError
78
+ @logger.log(Logger::ERROR, 'Error flushing ODP event queue.')
79
+ return
80
+ end
81
+
82
+ @mutex.synchronize do
83
+ @received.signal
84
+ end
85
+ end
86
+
87
+ def update_config
88
+ begin
89
+ # Adds update config signal to event_queue.
90
+ @event_queue.push(:UPDATE_CONFIG, true)
91
+ rescue ThreadError
92
+ @logger.log(Logger::ERROR, 'Error updating ODP config for the event queue')
93
+ end
94
+
95
+ @mutex.synchronize do
96
+ @received.signal
97
+ end
98
+ end
99
+
100
+ def dispatch(event)
101
+ if @thread_exception
102
+ @logger.log(Logger::ERROR, format(Helpers::Constants::ODP_LOGS[:ODP_EVENT_FAILED], 'Queue is down'))
103
+ return
104
+ end
105
+
106
+ # if the processor has been explicitly stopped. Don't accept tasks
107
+ unless running?
108
+ @logger.log(Logger::WARN, 'ODP event queue is shutdown, not accepting events.')
109
+ return
110
+ end
111
+
112
+ begin
113
+ @logger.log(Logger::DEBUG, 'ODP event queue: adding event.')
114
+ @event_queue.push(event, true)
115
+ rescue => e
116
+ @logger.log(Logger::WARN, format(Helpers::Constants::ODP_LOGS[:ODP_EVENT_FAILED], e.message))
117
+ return
118
+ end
119
+
120
+ @mutex.synchronize do
121
+ @received.signal
122
+ end
123
+ end
124
+
125
+ def send_event(type:, action:, identifiers:, data:)
126
+ case @odp_config&.odp_state
127
+ when nil
128
+ @logger.log(Logger::DEBUG, 'ODP event queue: cannot send before config has been set.')
129
+ return
130
+ when OdpConfig::ODP_CONFIG_STATE[:UNDETERMINED]
131
+ @logger.log(Logger::DEBUG, 'ODP event queue: cannot send before the datafile has loaded.')
132
+ return
133
+ when OdpConfig::ODP_CONFIG_STATE[:NOT_INTEGRATED]
134
+ @logger.log(Logger::DEBUG, Helpers::Constants::ODP_LOGS[:ODP_NOT_INTEGRATED])
135
+ return
136
+ end
137
+
138
+ event = Optimizely::OdpEvent.new(type: type, action: action, identifiers: identifiers, data: data)
139
+ dispatch(event)
140
+ end
141
+
142
+ def stop!
143
+ return unless running?
144
+
145
+ begin
146
+ @event_queue.push(:SHUTDOWN_SIGNAL, true)
147
+ rescue ThreadError
148
+ @logger.log(Logger::ERROR, 'Error stopping ODP event queue.')
149
+ return
150
+ end
151
+
152
+ @event_queue.close
153
+
154
+ @mutex.synchronize do
155
+ @received.signal
156
+ end
157
+
158
+ @logger.log(Logger::INFO, 'Stopping ODP event queue.')
159
+
160
+ @thread.join
161
+
162
+ @logger.log(Logger::ERROR, format(Helpers::Constants::ODP_LOGS[:ODP_EVENT_FAILED], @current_batch.to_json)) unless @current_batch.empty?
163
+ end
164
+
165
+ def running?
166
+ !!@thread && !!@thread.status && !@event_queue.closed?
167
+ end
168
+
169
+ private
170
+
171
+ def run
172
+ loop do
173
+ @mutex.synchronize do
174
+ @received.wait(@mutex, queue_timeout) if @event_queue.empty?
175
+ end
176
+
177
+ begin
178
+ item = @event_queue.pop(true)
179
+ rescue ThreadError => e
180
+ raise unless e.message == 'queue empty'
181
+
182
+ item = nil
183
+ end
184
+
185
+ case item
186
+ when :SHUTDOWN_SIGNAL
187
+ @logger.log(Logger::DEBUG, 'ODP event queue: received shutdown signal.')
188
+ break
189
+
190
+ when :FLUSH_SIGNAL
191
+ @logger.log(Logger::DEBUG, 'ODP event queue: received flush signal.')
192
+ flush_batch!
193
+
194
+ when :UPDATE_CONFIG
195
+ @logger.log(Logger::DEBUG, 'ODP event queue: received update config signal.')
196
+ process_config_update
197
+
198
+ when Optimizely::OdpEvent
199
+ add_to_batch(item)
200
+
201
+ when nil && !@current_batch.empty?
202
+ @logger.log(Logger::DEBUG, 'ODP event queue: flushing on interval.')
203
+ flush_batch!
204
+ end
205
+ end
206
+ rescue SignalException
207
+ @thread_exception = true
208
+ @logger.log(Logger::ERROR, 'Interrupted while processing ODP events.')
209
+ rescue => e
210
+ @thread_exception = true
211
+ @logger.log(Logger::ERROR, "Uncaught exception processing ODP events. Error: #{e.message}")
212
+ ensure
213
+ @logger.log(Logger::INFO, 'Exiting ODP processing loop. Attempting to flush pending events.')
214
+ flush_batch!
215
+ end
216
+
217
+ def flush_batch!
218
+ if @current_batch.empty?
219
+ @logger.log(Logger::DEBUG, 'ODP event queue: nothing to flush.')
220
+ return
221
+ end
222
+
223
+ if @api_key.nil? || @api_host.nil?
224
+ @logger.log(Logger::DEBUG, Helpers::Constants::ODP_LOGS[:ODP_NOT_INTEGRATED])
225
+ @current_batch.clear
226
+ return
227
+ end
228
+
229
+ @logger.log(Logger::DEBUG, "ODP event queue: flushing batch size #{@current_batch.length}.")
230
+ should_retry = false
231
+
232
+ i = 0
233
+ while i < @retry_count
234
+ begin
235
+ should_retry = @api_manager.send_odp_events(@api_key, @api_host, @current_batch)
236
+ rescue StandardError => e
237
+ should_retry = false
238
+ @logger.log(Logger::ERROR, format(Helpers::Constants::ODP_LOGS[:ODP_EVENT_FAILED], "Error: #{e.message} #{@current_batch.to_json}"))
239
+ end
240
+ break unless should_retry
241
+
242
+ @logger.log(Logger::DEBUG, 'Error dispatching ODP events, scheduled to retry.') if i < @retry_count
243
+ i += 1
244
+ end
245
+
246
+ @logger.log(Logger::ERROR, format(Helpers::Constants::ODP_LOGS[:ODP_EVENT_FAILED], "Failed after #{i} retries: #{@current_batch.to_json}")) if should_retry
247
+
248
+ @current_batch.clear
249
+ end
250
+
251
+ def add_to_batch(event)
252
+ set_flush_deadline if @current_batch.empty?
253
+
254
+ @current_batch << event
255
+ return unless @current_batch.length >= @batch_size
256
+
257
+ @logger.log(Logger::DEBUG, 'ODP event queue: flushing on batch size.')
258
+ flush_batch!
259
+ end
260
+
261
+ def set_flush_deadline
262
+ # Sets time that next flush will occur.
263
+ @flush_deadline = Time.new + @flush_interval
264
+ end
265
+
266
+ def time_till_flush
267
+ # Returns seconds until next flush; no less than 0.
268
+ [0, @flush_deadline - Time.new].max
269
+ end
270
+
271
+ def queue_timeout
272
+ # Returns seconds until next flush or None if current batch is empty.
273
+ return nil if @current_batch.empty?
274
+
275
+ time_till_flush
276
+ end
277
+
278
+ def process_config_update
279
+ # Updates the configuration used to send events.
280
+ flush_batch! unless @current_batch.empty?
281
+
282
+ @api_key = @odp_config&.api_key
283
+ @api_host = @odp_config&.api_host
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright 2022, Optimizely and contributors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+
19
+ require 'optimizely/logger'
20
+ require_relative '../helpers/constants'
21
+ require_relative '../helpers/validator'
22
+ require_relative '../exceptions'
23
+ require_relative 'odp_config'
24
+ require_relative 'lru_cache'
25
+ require_relative 'odp_segment_manager'
26
+ require_relative 'odp_event_manager'
27
+
28
+ module Optimizely
29
+ class OdpManager
30
+ ODP_LOGS = Helpers::Constants::ODP_LOGS
31
+ ODP_MANAGER_CONFIG = Helpers::Constants::ODP_MANAGER_CONFIG
32
+ ODP_CONFIG_STATE = Helpers::Constants::ODP_CONFIG_STATE
33
+
34
+ # update_odp_config must be called to complete initialization
35
+ def initialize(
36
+ disable:,
37
+ segments_cache: nil,
38
+ segment_manager: nil,
39
+ event_manager: nil,
40
+ fetch_segments_timeout: nil,
41
+ odp_event_timeout: nil,
42
+ odp_flush_interval: nil,
43
+ logger: nil
44
+ )
45
+ @enabled = !disable
46
+ @segment_manager = segment_manager
47
+ @event_manager = event_manager
48
+ @logger = logger || NoOpLogger.new
49
+ @odp_config = OdpConfig.new
50
+
51
+ unless @enabled
52
+ @logger.log(Logger::INFO, ODP_LOGS[:ODP_NOT_ENABLED])
53
+ return
54
+ end
55
+
56
+ unless @segment_manager
57
+ segments_cache ||= LRUCache.new(
58
+ Helpers::Constants::ODP_SEGMENTS_CACHE_CONFIG[:DEFAULT_CAPACITY],
59
+ Helpers::Constants::ODP_SEGMENTS_CACHE_CONFIG[:DEFAULT_TIMEOUT_SECONDS]
60
+ )
61
+ @segment_manager = Optimizely::OdpSegmentManager.new(segments_cache, nil, @logger, timeout: fetch_segments_timeout)
62
+ end
63
+
64
+ @event_manager ||= Optimizely::OdpEventManager.new(logger: @logger, request_timeout: odp_event_timeout, flush_interval: odp_flush_interval)
65
+
66
+ @segment_manager.odp_config = @odp_config
67
+ end
68
+
69
+ def fetch_qualified_segments(user_id:, options:)
70
+ # Returns qualified segments for the user from the cache or the ODP server if not in the cache.
71
+ #
72
+ # @param user_id - The user id.
73
+ # @param options - An array of OptimizelySegmentOptions used to ignore and/or reset the cache.
74
+ #
75
+ # @return - Array of qualified segments or nil.
76
+ options ||= []
77
+ unless @enabled
78
+ @logger.log(Logger::ERROR, ODP_LOGS[:ODP_NOT_ENABLED])
79
+ return nil
80
+ end
81
+
82
+ if @odp_config.odp_state == ODP_CONFIG_STATE[:UNDETERMINED]
83
+ @logger.log(Logger::ERROR, 'Cannot fetch segments before the datafile has loaded.')
84
+ return nil
85
+ end
86
+
87
+ @segment_manager.fetch_qualified_segments(ODP_MANAGER_CONFIG[:KEY_FOR_USER_ID], user_id, options)
88
+ end
89
+
90
+ def identify_user(user_id:)
91
+ unless @enabled
92
+ @logger.log(Logger::DEBUG, 'ODP identify event is not dispatched (ODP disabled).')
93
+ return
94
+ end
95
+
96
+ case @odp_config.odp_state
97
+ when ODP_CONFIG_STATE[:UNDETERMINED]
98
+ @logger.log(Logger::DEBUG, 'ODP identify event is not dispatched (datafile not ready).')
99
+ return
100
+ when ODP_CONFIG_STATE[:NOT_INTEGRATED]
101
+ @logger.log(Logger::DEBUG, 'ODP identify event is not dispatched (ODP not integrated).')
102
+ return
103
+ end
104
+
105
+ @event_manager.send_event(
106
+ type: ODP_MANAGER_CONFIG[:EVENT_TYPE],
107
+ action: 'identified',
108
+ identifiers: {ODP_MANAGER_CONFIG[:KEY_FOR_USER_ID] => user_id},
109
+ data: {}
110
+ )
111
+ end
112
+
113
+ def send_event(type:, action:, identifiers:, data:)
114
+ # Send an event to the ODP server.
115
+ #
116
+ # @param type - the event type.
117
+ # @param action - the event action name.
118
+ # @param identifiers - a hash for identifiers.
119
+ # @param data - a hash for associated data. The default event data will be added to this data before sending to the ODP server.
120
+ unless @enabled
121
+ @logger.log(Logger::ERROR, ODP_LOGS[:ODP_NOT_ENABLED])
122
+ return
123
+ end
124
+
125
+ unless Helpers::Validator.odp_data_types_valid?(data)
126
+ @logger.log(Logger::ERROR, ODP_LOGS[:ODP_INVALID_DATA])
127
+ return
128
+ end
129
+
130
+ @event_manager.send_event(type: type, action: action, identifiers: identifiers, data: data)
131
+ end
132
+
133
+ def update_odp_config(api_key, api_host, segments_to_check)
134
+ # Update the odp config, reset the cache and send signal to the event processor to update its config.
135
+ # Start the event manager if odp is integrated.
136
+ return unless @enabled
137
+
138
+ config_changed = @odp_config.update(api_key, api_host, segments_to_check)
139
+ unless config_changed
140
+ @logger.log(Logger::DEBUG, 'Odp config was not changed.')
141
+ return
142
+ end
143
+
144
+ @segment_manager.reset
145
+
146
+ if @event_manager.running?
147
+ @event_manager.update_config
148
+ elsif @odp_config.odp_state == ODP_CONFIG_STATE[:INTEGRATED]
149
+ @event_manager.start!(@odp_config)
150
+ end
151
+ end
152
+
153
+ def stop!
154
+ return unless @enabled
155
+
156
+ @event_manager.stop!
157
+ end
158
+ end
159
+ end