ff-ruby-server-sdk 1.4.3 → 1.4.4
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 +4 -4
- data/lib/ff/ruby/server/sdk/api/evaluator.rb +15 -0
- data/lib/ff/ruby/server/sdk/api/inner_client.rb +62 -42
- data/lib/ff/ruby/server/sdk/api/metrics_event.rb +16 -1
- data/lib/ff/ruby/server/sdk/api/metrics_processor.rb +81 -91
- data/lib/ff/ruby/server/sdk/api/update_processor.rb +1 -1
- data/lib/ff/ruby/server/sdk/version.rb +1 -1
- data/scripts/sdk_specs.sh +1 -1
- metadata +4 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c9ee87181e0d3822c070ba3c889bf4b339b1c4e7853b046885c98372d25f7a5e
|
4
|
+
data.tar.gz: 3692d620f62ff96a59b17a4bcbd939f245e0ae1befcb9b477bb7389d5beca7e7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b9c6768454e988de4a4829b9c34d3e168ad43ad5a6d5100ca9671bac272714074b5245fe688aaa0bac81da820f1c172e4cc3fb6ceb577e444b22e833d4578980
|
7
|
+
data.tar.gz: 404694820857c6e8959ac6fdc711bf4119779c0b52f8e48ee56c38e27814a6d7bbe024d7423be9d6e90549c087b68da0add84f7a3911b9a820102da621641d27
|
@@ -91,6 +91,20 @@ class Evaluator < Evaluation
|
|
91
91
|
|
92
92
|
flag = @repository.get_flag(identifier)
|
93
93
|
|
94
|
+
# Check if flag exists
|
95
|
+
if flag.nil?
|
96
|
+
# Log a warning if the flag is not found
|
97
|
+
@logger.warn "Flag not found for identifier '#{identifier}'. Serving default variation."
|
98
|
+
return nil
|
99
|
+
end
|
100
|
+
|
101
|
+
# Check if the flag's kind matches the expected type
|
102
|
+
unless flag.kind == expected
|
103
|
+
@logger.warn "Flag kind mismatch: expected '#{expected}', but got '#{flag.kind}' for identifier '#{identifier}'. Serving default variation."
|
104
|
+
return nil
|
105
|
+
end
|
106
|
+
|
107
|
+
# Proceed with prerequisite check if flag type is as expected
|
94
108
|
if flag != nil && flag.kind == expected
|
95
109
|
unless flag.prerequisites.empty?
|
96
110
|
pre_req = check_pre_requisite(flag, target)
|
@@ -109,6 +123,7 @@ class Evaluator < Evaluation
|
|
109
123
|
end
|
110
124
|
end
|
111
125
|
|
126
|
+
# Returning nil will indicate to callers to serve the default variation
|
112
127
|
nil
|
113
128
|
end
|
114
129
|
|
@@ -50,6 +50,7 @@ class InnerClient < ClientCallback
|
|
50
50
|
|
51
51
|
@connector = connector
|
52
52
|
end
|
53
|
+
@condition = ConditionVariable.new
|
53
54
|
|
54
55
|
@closing = false
|
55
56
|
@failure = false
|
@@ -66,22 +67,34 @@ class InnerClient < ClientCallback
|
|
66
67
|
end
|
67
68
|
|
68
69
|
def bool_variation(identifier, target, default_value)
|
69
|
-
|
70
|
+
unless @initialized
|
71
|
+
log_sdk_not_initialized_warning(identifier, default_value)
|
72
|
+
return default_value
|
73
|
+
end
|
70
74
|
@evaluator.bool_variation(identifier, target, default_value, @evaluator_callback)
|
71
75
|
end
|
72
76
|
|
73
77
|
def string_variation(identifier, target, default_value)
|
74
|
-
|
78
|
+
unless @initialized
|
79
|
+
log_sdk_not_initialized_warning(identifier, default_value)
|
80
|
+
return default_value
|
81
|
+
end
|
75
82
|
@evaluator.string_variation(identifier, target, default_value, @evaluator_callback)
|
76
83
|
end
|
77
84
|
|
78
85
|
def number_variation(identifier, target, default_value)
|
79
|
-
|
86
|
+
unless @initialized
|
87
|
+
log_sdk_not_initialized_warning(identifier, default_value)
|
88
|
+
return default_value
|
89
|
+
end
|
80
90
|
@evaluator.number_variation(identifier, target, default_value, @evaluator_callback)
|
81
91
|
end
|
82
92
|
|
83
93
|
def json_variation(identifier, target, default_value)
|
84
|
-
|
94
|
+
unless @initialized
|
95
|
+
log_sdk_not_initialized_warning(identifier, default_value)
|
96
|
+
return default_value
|
97
|
+
end
|
85
98
|
@evaluator.json_variation(identifier, target, default_value, @evaluator_callback)
|
86
99
|
end
|
87
100
|
|
@@ -109,6 +122,7 @@ class InnerClient < ClientCallback
|
|
109
122
|
def on_auth_failed
|
110
123
|
SdkCodes::warn_auth_failed_srv_defaults @config.logger
|
111
124
|
@initialized = true
|
125
|
+
@condition.signal
|
112
126
|
end
|
113
127
|
|
114
128
|
def close
|
@@ -195,67 +209,74 @@ class InnerClient < ClientCallback
|
|
195
209
|
end
|
196
210
|
|
197
211
|
def on_processor_ready(processor)
|
212
|
+
@my_mutex.synchronize do
|
198
213
|
|
199
|
-
|
214
|
+
if @closing
|
200
215
|
|
201
|
-
|
202
|
-
|
216
|
+
return
|
217
|
+
end
|
203
218
|
|
204
|
-
|
219
|
+
if processor == @poll_processor
|
205
220
|
|
206
|
-
|
207
|
-
|
208
|
-
|
221
|
+
@poller_ready = true
|
222
|
+
@config.logger.debug "PollingProcessor ready"
|
223
|
+
end
|
209
224
|
|
210
|
-
|
225
|
+
if processor == @update_processor
|
211
226
|
|
212
|
-
|
213
|
-
|
214
|
-
|
227
|
+
@stream_ready = true
|
228
|
+
@config.logger.debug "Updater ready"
|
229
|
+
end
|
215
230
|
|
216
|
-
|
231
|
+
if processor == @metrics_processor
|
217
232
|
|
218
|
-
|
219
|
-
|
220
|
-
|
233
|
+
@metrics_ready = true
|
234
|
+
@config.logger.debug "Metrics ready"
|
235
|
+
end
|
221
236
|
|
222
|
-
|
223
|
-
|
224
|
-
|
237
|
+
if (@config.stream_enabled && !@stream_ready) ||
|
238
|
+
(@config.analytics_enabled && !@metrics_ready) ||
|
239
|
+
!@poller_ready
|
225
240
|
|
226
|
-
|
227
|
-
|
241
|
+
return
|
242
|
+
end
|
228
243
|
|
229
|
-
|
244
|
+
SdkCodes.info_sdk_init_ok @config.logger
|
230
245
|
|
231
|
-
|
246
|
+
@condition.signal
|
247
|
+
@initialized = true
|
248
|
+
end
|
232
249
|
end
|
233
250
|
|
234
251
|
def wait_for_initialization(timeout: nil)
|
235
|
-
|
236
|
-
|
252
|
+
SdkCodes::info_sdk_waiting_to_initialize(@config.logger, timeout)
|
253
|
+
return if @initialized
|
237
254
|
|
255
|
+
@my_mutex.synchronize do
|
238
256
|
start_time = Time.now
|
257
|
+
remaining = timeout ? timeout / 1000.0 : nil # Convert timeout to seconds
|
239
258
|
|
240
259
|
until @initialized
|
241
|
-
# Check if a timeout is specified and has been exceeded
|
242
|
-
if timeout && (Time.now - start_time) > (timeout / 1000.0)
|
243
|
-
@config.logger.warn "The SDK has timed out waiting to initialize with supplied timeout #{timeout} ms"
|
244
|
-
handle_initialization_failure
|
245
|
-
end
|
246
260
|
|
247
|
-
|
248
|
-
|
261
|
+
# Break if timeout has elapsed
|
262
|
+
if remaining && remaining <= 0
|
263
|
+
@config.logger.warn "The SDK has timed out waiting to initialize with supplied timeout #{timeout} ms. The SDK will continue to initialize in the background. Default variations will be served until the SDK initializes."
|
264
|
+
break
|
265
|
+
end
|
266
|
+
# Wait for the signal or timeout
|
267
|
+
@condition.wait(@my_mutex, remaining)
|
249
268
|
|
250
|
-
|
251
|
-
|
269
|
+
# Recalculate the remaining time after the wait
|
270
|
+
if timeout
|
271
|
+
elapsed = Time.now - start_time
|
272
|
+
remaining = (timeout / 1000.0) - elapsed
|
273
|
+
end
|
252
274
|
end
|
253
275
|
|
254
|
-
@config.logger.debug "Waiting for initialization has completed"
|
276
|
+
@config.logger.debug "Waiting for initialization has completed" if @initialized
|
255
277
|
end
|
256
278
|
end
|
257
279
|
|
258
|
-
|
259
280
|
protected
|
260
281
|
|
261
282
|
def handle_initialization_failure
|
@@ -316,9 +337,8 @@ class InnerClient < ClientCallback
|
|
316
337
|
|
317
338
|
private
|
318
339
|
|
319
|
-
def
|
320
|
-
|
321
|
-
@my_mutex.synchronize(&block)
|
340
|
+
def log_sdk_not_initialized_warning(identifier, default_value)
|
341
|
+
@config.logger.warn "SDKCODE:6001: SDK is not initialized; serving default variation for bool variation: identifier=#{identifier}, default=#{default_value}"
|
322
342
|
end
|
323
343
|
|
324
344
|
end
|
@@ -21,7 +21,11 @@ class MetricsEvent
|
|
21
21
|
# project sizes. Issue being tracked in FFM-12192, and once resolved, can feasibly remove
|
22
22
|
# these checks in a future release.
|
23
23
|
unless other.is_a?(MetricsEvent)
|
24
|
-
|
24
|
+
# We should always have a logger available except when we've deep cloned this class. We don't do any
|
25
|
+
# equality check on clones in metrics code anyway, so this is just a safety check.
|
26
|
+
if @logger
|
27
|
+
@logger.warn("Warning: Attempted to compare MetricsEvent with #{other.class.name}")
|
28
|
+
end
|
25
29
|
return false
|
26
30
|
end
|
27
31
|
|
@@ -35,4 +39,15 @@ class MetricsEvent
|
|
35
39
|
end
|
36
40
|
|
37
41
|
|
42
|
+
# Exclude logger from serialization
|
43
|
+
def marshal_dump
|
44
|
+
[@feature_config, @target, @variation]
|
45
|
+
end
|
46
|
+
|
47
|
+
def marshal_load(array)
|
48
|
+
@feature_config, @target, @variation = array
|
49
|
+
@logger = nil
|
50
|
+
end
|
51
|
+
|
52
|
+
|
38
53
|
end
|
@@ -1,5 +1,6 @@
|
|
1
1
|
require "time"
|
2
|
-
require
|
2
|
+
require 'thread'
|
3
|
+
require "set"
|
3
4
|
|
4
5
|
require_relative "../dto/target"
|
5
6
|
require_relative "../../sdk/version"
|
@@ -11,40 +12,6 @@ require_relative "../api/summary_metrics"
|
|
11
12
|
class MetricsProcessor < Closeable
|
12
13
|
GLOBAL_TARGET = Target.new(identifier: "__global__cf_target", name: "Global Target").freeze
|
13
14
|
|
14
|
-
class FrequencyMap < Concurrent::Map
|
15
|
-
def initialize(options = nil, &block)
|
16
|
-
super
|
17
|
-
end
|
18
|
-
|
19
|
-
def increment(key)
|
20
|
-
compute(key) do |old_value|
|
21
|
-
if old_value == nil;
|
22
|
-
1
|
23
|
-
else
|
24
|
-
old_value + 1
|
25
|
-
end
|
26
|
-
end
|
27
|
-
end
|
28
|
-
|
29
|
-
def get(key)
|
30
|
-
self[key]
|
31
|
-
end
|
32
|
-
|
33
|
-
# TODO Will be removed in V2 in favour of simplified clearing. Currently not used outside of tests.
|
34
|
-
def drain_to_map
|
35
|
-
result = {}
|
36
|
-
each_key do |key|
|
37
|
-
result[key] = 0
|
38
|
-
end
|
39
|
-
result.each_key do |key|
|
40
|
-
value = get_and_set(key, 0)
|
41
|
-
result[key] = value
|
42
|
-
delete_pair(key, 0)
|
43
|
-
end
|
44
|
-
result
|
45
|
-
end
|
46
|
-
end
|
47
|
-
|
48
15
|
def init(connector, config, callback)
|
49
16
|
|
50
17
|
unless connector.kind_of?(Connector)
|
@@ -76,15 +43,20 @@ class MetricsProcessor < Closeable
|
|
76
43
|
@feature_name_attribute = "featureName"
|
77
44
|
@variation_identifier_attribute = "variationIdentifier"
|
78
45
|
|
79
|
-
|
80
|
-
|
81
|
-
# Used for locking the evalution and target metrics maps before we clone them
|
46
|
+
# Evaluation and target metrics
|
82
47
|
@metric_maps_mutex = Mutex.new
|
83
|
-
@evaluation_metrics =
|
84
|
-
@target_metrics =
|
48
|
+
@evaluation_metrics = {}
|
49
|
+
@target_metrics = {}
|
85
50
|
|
86
|
-
# Keep track of targets that have already been sent to avoid sending them again
|
87
|
-
|
51
|
+
# Keep track of targets that have already been sent to avoid sending them again. We track a max 500K targets
|
52
|
+
# to prevent unbounded growth.
|
53
|
+
@seen_targets_mutex = Mutex.new
|
54
|
+
@seen_targets = Set.new
|
55
|
+
@max_seen_targets = 500000
|
56
|
+
@seen_targets_full = false
|
57
|
+
|
58
|
+
# Mutex to protect aggregation and sending metrics at the end of an interval
|
59
|
+
@send_data_mutex = Mutex.new
|
88
60
|
|
89
61
|
@callback.on_metrics_ready
|
90
62
|
end
|
@@ -118,9 +90,9 @@ class MetricsProcessor < Closeable
|
|
118
90
|
|
119
91
|
def register_evaluation_metric(feature_config, variation)
|
120
92
|
# Guard clause to ensure feature_config, @global_target, and variation are valid.
|
121
|
-
# While they should be, this adds protection for an edge case we are seeing with
|
122
|
-
#
|
123
|
-
# these checks in a future release.
|
93
|
+
# While they should be, this adds protection for an edge case we are seeing where the the ConcurrentMap (now replaced with our own thread safe hash)
|
94
|
+
# seemed to be accessing invalid areas of memory and seg faulting.
|
95
|
+
# Issue being tracked in FFM-12192, and once resolved, can remove these checks in a future release && once the issue is resolved.
|
124
96
|
if feature_config.nil? || !feature_config.respond_to?(:feature) || feature_config.feature.nil?
|
125
97
|
@config.logger.warn("Skipping invalid MetricsEvent: feature_config is missing or incomplete. feature_config=#{feature_config.inspect}")
|
126
98
|
return
|
@@ -138,72 +110,81 @@ class MetricsProcessor < Closeable
|
|
138
110
|
|
139
111
|
event = MetricsEvent.new(feature_config, GLOBAL_TARGET, variation, @config.logger)
|
140
112
|
@metric_maps_mutex.synchronize do
|
141
|
-
@evaluation_metrics
|
113
|
+
@evaluation_metrics[event] = (@evaluation_metrics[event] || 0) + 1
|
142
114
|
end
|
143
115
|
end
|
144
116
|
|
145
117
|
def register_target_metric(target)
|
146
|
-
if target.is_private
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
118
|
+
return if target.is_private
|
119
|
+
|
120
|
+
add_to_target_metrics = @seen_targets_mutex.synchronize do
|
121
|
+
# If the set is full, directly allow adding to target_metrics
|
122
|
+
if @seen_targets_full
|
123
|
+
true
|
124
|
+
elsif @seen_targets.include?(target.identifier)
|
125
|
+
false
|
126
|
+
else
|
127
|
+
@seen_targets.add(target.identifier)
|
128
|
+
@seen_targets_full = @seen_targets.size >= @max_seen_targets
|
129
|
+
true
|
130
|
+
end
|
154
131
|
end
|
155
132
|
|
133
|
+
# Add to target_metrics if marked for inclusion
|
156
134
|
@metric_maps_mutex.synchronize do
|
157
|
-
@target_metrics
|
158
|
-
end
|
135
|
+
@target_metrics[target.identifier] = target
|
136
|
+
end if add_to_target_metrics
|
159
137
|
end
|
160
138
|
|
139
|
+
|
161
140
|
def run_one_iteration
|
162
141
|
send_data_and_reset_cache(@evaluation_metrics, @target_metrics)
|
163
142
|
end
|
164
143
|
|
165
144
|
def send_data_and_reset_cache(evaluation_metrics_map, target_metrics_map)
|
166
|
-
|
167
|
-
|
168
|
-
# metrics to be processed in an atomic unit.
|
169
|
-
evaluation_metrics_map_clone, target_metrics_map_clone = @metric_maps_mutex.synchronize do
|
170
|
-
|
171
|
-
clone_evaluations = Concurrent::Map.new
|
172
|
-
clone_targets = Concurrent::Map.new
|
173
|
-
# Clone and clear evaluation metrics map
|
174
|
-
evaluation_metrics_map.each_pair do |key, value|
|
175
|
-
clone_evaluations[key] = value
|
176
|
-
end
|
145
|
+
@send_data_mutex.synchronize do
|
146
|
+
begin
|
177
147
|
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
148
|
+
evaluation_metrics_map_clone, target_metrics_map_clone = @metric_maps_mutex.synchronize do
|
149
|
+
# Check if we have metrics to send; if not, skip sending metrics
|
150
|
+
if evaluation_metrics_map.empty? && target_metrics_map.empty?
|
151
|
+
@config.logger.debug "No metrics to send. Skipping sending metrics this interval"
|
152
|
+
return
|
153
|
+
end
|
183
154
|
|
184
|
-
|
155
|
+
# Deep clone the evaluation metrics
|
156
|
+
cloned_evaluations = Marshal.load(Marshal.dump(evaluation_metrics_map)).freeze
|
157
|
+
evaluation_metrics_map.clear
|
185
158
|
|
186
|
-
|
159
|
+
# Deep clone the target metrics
|
160
|
+
cloned_targets = Marshal.load(Marshal.dump(target_metrics_map)).freeze
|
161
|
+
target_metrics_map.clear
|
162
|
+
[cloned_evaluations, cloned_targets]
|
187
163
|
|
188
|
-
|
164
|
+
end
|
189
165
|
|
190
|
-
|
166
|
+
metrics = prepare_summary_metrics_body(evaluation_metrics_map_clone, target_metrics_map_clone)
|
191
167
|
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
168
|
+
unless metrics.metrics_data.empty?
|
169
|
+
start_time = (Time.now.to_f * 1000).to_i
|
170
|
+
@connector.post_metrics(metrics)
|
171
|
+
end_time = (Time.now.to_f * 1000).to_i
|
172
|
+
if end_time - start_time > @config.metrics_service_acceptable_duration
|
173
|
+
@config.logger.debug "Metrics service API duration=[" + (end_time - start_time).to_s + "]"
|
174
|
+
end
|
175
|
+
end
|
176
|
+
rescue => e
|
177
|
+
@config.logger.warn "Error when preparing and sending metrics: #{e.message}"
|
178
|
+
@config.logger.warn e.backtrace&.join("\n") || "No backtrace available"
|
198
179
|
end
|
199
180
|
end
|
200
181
|
end
|
201
182
|
|
202
|
-
def prepare_summary_metrics_body(
|
183
|
+
def prepare_summary_metrics_body(evaluation_metrics_clone, target_metrics_clone)
|
203
184
|
metrics = OpenapiClient::Metrics.new({ :target_data => [], :metrics_data => [] })
|
204
185
|
|
205
186
|
total_count = 0
|
206
|
-
|
187
|
+
evaluation_metrics_clone.each do |key, value|
|
207
188
|
# While Components should not be missing, this adds protection for an edge case we are seeing with very large
|
208
189
|
# project sizes. Issue being tracked in FFM-12192, and once resolved, can feasibly remove
|
209
190
|
# these checks in a future release.
|
@@ -235,9 +216,9 @@ class MetricsProcessor < Closeable
|
|
235
216
|
metrics_data.attributes.push(OpenapiClient::KeyValue.new({ :key => @sdk_version, :value => @jar_version }))
|
236
217
|
metrics.metrics_data.push(metrics_data)
|
237
218
|
end
|
238
|
-
@config.logger.debug "Pushed #{total_count} metric evaluations to server. metrics_data count is #{
|
219
|
+
@config.logger.debug "Pushed #{total_count} metric evaluations to server. metrics_data count is #{evaluation_metrics_clone.size}. target_data count is #{target_metrics_clone.size}"
|
239
220
|
|
240
|
-
|
221
|
+
target_metrics_clone.each_pair do |_, value|
|
241
222
|
add_target_data(metrics, value)
|
242
223
|
end
|
243
224
|
|
@@ -276,18 +257,27 @@ class MetricsProcessor < Closeable
|
|
276
257
|
@running = true
|
277
258
|
@thread = Thread.new do
|
278
259
|
@config.logger.debug "Async started: " + self.to_s
|
279
|
-
|
260
|
+
mutex = Mutex.new
|
261
|
+
condition = ConditionVariable.new
|
262
|
+
|
263
|
+
while @ready
|
280
264
|
unless @initialized
|
281
265
|
@initialized = true
|
282
|
-
SdkCodes::info_metrics_thread_started
|
266
|
+
SdkCodes::info_metrics_thread_started(@config.logger)
|
283
267
|
end
|
284
|
-
|
285
|
-
|
268
|
+
|
269
|
+
mutex.synchronize do
|
270
|
+
# Wait for the specified interval or until notified
|
271
|
+
condition.wait(mutex, @config.frequency)
|
272
|
+
end
|
273
|
+
|
274
|
+
# Re-check @ready before running the iteration
|
275
|
+
run_one_iteration if @ready
|
286
276
|
end
|
287
277
|
end
|
288
|
-
@thread.run
|
289
278
|
end
|
290
279
|
|
280
|
+
|
291
281
|
def stop_async
|
292
282
|
@ready = false
|
293
283
|
@initialized = false
|
data/scripts/sdk_specs.sh
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ff-ruby-server-sdk
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.4.
|
4
|
+
version: 1.4.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- 'Miloš Vasić, cyr.: Милош Васић'
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-11-
|
11
|
+
date: 2024-11-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rake
|
@@ -142,14 +142,14 @@ dependencies:
|
|
142
142
|
requirements:
|
143
143
|
- - '='
|
144
144
|
- !ruby/object:Gem::Version
|
145
|
-
version: 0.1.
|
145
|
+
version: 0.1.7
|
146
146
|
type: :runtime
|
147
147
|
prerelease: false
|
148
148
|
version_requirements: !ruby/object:Gem::Requirement
|
149
149
|
requirements:
|
150
150
|
- - '='
|
151
151
|
- !ruby/object:Gem::Version
|
152
|
-
version: 0.1.
|
152
|
+
version: 0.1.7
|
153
153
|
- !ruby/object:Gem::Dependency
|
154
154
|
name: typhoeus
|
155
155
|
requirement: !ruby/object:Gem::Requirement
|