ldclient-rb 0.4.0 → 0.5.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 +4 -4
- data/.hound.yml +2 -0
- data/.rspec +2 -0
- data/.rubocop.yml +601 -0
- data/.simplecov +4 -0
- data/CONTRIBUTING.md +10 -0
- data/Gemfile +1 -1
- data/LICENSE.txt +1 -1
- data/README.md +72 -13
- data/Rakefile +3 -0
- data/circle.yml +29 -0
- data/ldclient-rb.gemspec +8 -6
- data/lib/ldclient-rb/config.rb +73 -76
- data/lib/ldclient-rb/ldclient.rb +201 -199
- data/lib/ldclient-rb/newrelic.rb +4 -7
- data/lib/ldclient-rb/store.rb +10 -11
- data/lib/ldclient-rb/stream.rb +67 -67
- data/lib/ldclient-rb/version.rb +1 -1
- data/spec/config_spec.rb +45 -0
- data/spec/fixtures/feature.json +67 -0
- data/spec/fixtures/user.json +9 -0
- data/spec/ldclient_spec.rb +226 -0
- data/spec/newrelic_spec.rb +5 -0
- data/spec/spec_helper.rb +9 -0
- data/spec/store_spec.rb +10 -0
- data/spec/stream_spec.rb +118 -0
- data/spec/version_spec.rb +7 -0
- metadata +82 -31
data/lib/ldclient-rb/ldclient.rb
CHANGED
@@ -1,32 +1,30 @@
|
|
1
|
-
require
|
2
|
-
require
|
3
|
-
require
|
4
|
-
require
|
5
|
-
require
|
6
|
-
require
|
7
|
-
require
|
8
|
-
require
|
1
|
+
require "faraday/http_cache"
|
2
|
+
require "json"
|
3
|
+
require "digest/sha1"
|
4
|
+
require "thread"
|
5
|
+
require "logger"
|
6
|
+
require "net/http/persistent"
|
7
|
+
require "benchmark"
|
8
|
+
require "hashdiff"
|
9
9
|
|
10
10
|
module LaunchDarkly
|
11
|
-
|
12
11
|
BUILTINS = [:key, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous]
|
13
12
|
|
14
|
-
#
|
15
|
-
# A client for the LaunchDarkly API. Client instances are thread-safe. Users
|
13
|
+
#
|
14
|
+
# A client for the LaunchDarkly API. Client instances are thread-safe. Users
|
16
15
|
# should create a single client instance for the lifetime of the application.
|
17
|
-
#
|
18
|
-
#
|
16
|
+
#
|
17
|
+
#
|
19
18
|
class LDClient
|
20
|
-
|
21
|
-
#
|
19
|
+
#
|
22
20
|
# Creates a new client instance that connects to LaunchDarkly. A custom
|
23
21
|
# configuration parameter can also supplied to specify advanced options,
|
24
22
|
# but for most use cases, the default configuration is appropriate.
|
25
|
-
#
|
26
|
-
#
|
23
|
+
#
|
24
|
+
#
|
27
25
|
# @param api_key [String] the API key for your LaunchDarkly account
|
28
26
|
# @param config [Config] an optional client configuration object
|
29
|
-
#
|
27
|
+
#
|
30
28
|
# @return [LDClient] The LaunchDarkly client instance
|
31
29
|
def initialize(api_key, config = Config.default)
|
32
30
|
@queue = Queue.new
|
@@ -43,211 +41,207 @@ module LaunchDarkly
|
|
43
41
|
@stream_processor = StreamProcessor.new(api_key, config)
|
44
42
|
end
|
45
43
|
|
46
|
-
@worker = create_worker
|
44
|
+
@worker = create_worker
|
47
45
|
end
|
48
46
|
|
49
|
-
def flush
|
47
|
+
def flush
|
50
48
|
events = []
|
51
|
-
num_events = @queue.length()
|
52
49
|
begin
|
53
|
-
|
50
|
+
loop do
|
54
51
|
events << @queue.pop(true)
|
55
52
|
end
|
56
|
-
rescue
|
53
|
+
rescue ThreadError
|
57
54
|
end
|
58
55
|
|
59
|
-
if !events.empty?
|
60
|
-
|
61
|
-
|
62
|
-
req.headers['Authorization'] = 'api_key ' + @api_key
|
63
|
-
req.headers['User-Agent'] = 'RubyClient/' + LaunchDarkly::VERSION
|
64
|
-
req.headers['Content-Type'] = 'application/json'
|
65
|
-
req.body = events.to_json
|
66
|
-
req.options.timeout = @config.read_timeout
|
67
|
-
req.options.open_timeout = @config.connect_timeout
|
68
|
-
end
|
69
|
-
}
|
70
|
-
if res.status != 200
|
71
|
-
@config.logger.error("[LDClient] Unexpected status code while processing events: #{res.status}")
|
72
|
-
end
|
73
|
-
end
|
56
|
+
if !events.empty?
|
57
|
+
post_flushed_events(events)
|
58
|
+
end
|
74
59
|
end
|
75
60
|
|
61
|
+
def post_flushed_events(events)
|
62
|
+
res = log_timings("Flush events") do
|
63
|
+
next @client.post (@config.base_uri + "/api/events/bulk") do |req|
|
64
|
+
req.headers["Authorization"] = "api_key " + @api_key
|
65
|
+
req.headers["User-Agent"] = "RubyClient/" + LaunchDarkly::VERSION
|
66
|
+
req.headers["Content-Type"] = "application/json"
|
67
|
+
req.body = events.to_json
|
68
|
+
req.options.timeout = @config.read_timeout
|
69
|
+
req.options.open_timeout = @config.connect_timeout
|
70
|
+
end
|
71
|
+
end
|
72
|
+
if res.status / 100 != 2
|
73
|
+
@config.logger.error("[LDClient] Unexpected status code while processing events: #{res.status}")
|
74
|
+
end
|
75
|
+
end
|
76
76
|
|
77
|
-
def create_worker
|
77
|
+
def create_worker
|
78
78
|
Thread.new do
|
79
|
-
|
79
|
+
loop do
|
80
80
|
begin
|
81
|
-
flush
|
81
|
+
flush
|
82
82
|
|
83
83
|
sleep(@config.flush_interval)
|
84
|
-
rescue
|
85
|
-
|
84
|
+
rescue StandardError => exn
|
85
|
+
log_exception(__method__.to_s, exn)
|
86
86
|
end
|
87
87
|
end
|
88
88
|
end
|
89
89
|
end
|
90
90
|
|
91
|
-
def get_flag?(key, user, default=false)
|
91
|
+
def get_flag?(key, user, default = false)
|
92
92
|
toggle?(key, user, default)
|
93
93
|
end
|
94
94
|
|
95
|
-
#
|
96
|
-
# Calculates the value of a feature flag for a given user. At a minimum,
|
97
|
-
# should contain a +:key+ .
|
98
|
-
#
|
95
|
+
#
|
96
|
+
# Calculates the value of a feature flag for a given user. At a minimum,
|
97
|
+
# the user hash should contain a +:key+ .
|
98
|
+
#
|
99
99
|
# @example Basic user hash
|
100
|
-
# {:
|
101
|
-
#
|
102
|
-
# For authenticated users, the +:key+ should be the unique identifier for
|
103
|
-
# the +:key+ should be a session identifier
|
104
|
-
#
|
105
|
-
#
|
100
|
+
# {key: "user@example.com"}
|
101
|
+
#
|
102
|
+
# For authenticated users, the +:key+ should be the unique identifier for
|
103
|
+
# your user. For anonymous users, the +:key+ should be a session identifier
|
104
|
+
# or cookie. In either case, the only requirement is that the key
|
105
|
+
# is unique to a user.
|
106
|
+
#
|
106
107
|
# You can also pass IP addresses and country codes in the user hash.
|
107
|
-
#
|
108
|
+
#
|
108
109
|
# @example More complete user hash
|
109
|
-
#
|
110
|
-
#
|
110
|
+
# {key: "user@example.com", ip: "127.0.0.1", country: "US"}
|
111
|
+
#
|
111
112
|
# Countries should be sent as ISO 3166-1 alpha-2 codes.
|
112
|
-
#
|
113
|
+
#
|
113
114
|
# The user hash can contain arbitrary custom attributes stored in a +:custom+ sub-hash:
|
114
|
-
#
|
115
|
+
#
|
115
116
|
# @example A user hash with custom attributes
|
116
|
-
#
|
117
|
-
#
|
118
|
-
# Attribute values in the custom hash can be integers, booleans, strings, or
|
119
|
-
#
|
120
|
-
#
|
117
|
+
# {key: "user@example.com", custom: {customer_rank: 1000, groups: ["google", "microsoft"]}}
|
118
|
+
#
|
119
|
+
# Attribute values in the custom hash can be integers, booleans, strings, or
|
120
|
+
# lists of integers, booleans, or strings.
|
121
|
+
#
|
122
|
+
# @param key [String] the unique feature key for the feature flag, as shown
|
123
|
+
# on the LaunchDarkly dashboard
|
121
124
|
# @param user [Hash] a hash containing parameters for the end user requesting the flag
|
122
125
|
# @param default=false [Boolean] the default value of the flag
|
123
|
-
#
|
124
|
-
# @return [Boolean] whether or not the flag should be enabled, or the
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
return default
|
134
|
-
end
|
126
|
+
#
|
127
|
+
# @return [Boolean] whether or not the flag should be enabled, or the
|
128
|
+
# default value if the flag is disabled on the LaunchDarkly control panel
|
129
|
+
def toggle?(key, user, default = false)
|
130
|
+
return default if @offline
|
131
|
+
|
132
|
+
unless user
|
133
|
+
@config.logger.error("[LDClient] Must specify user")
|
134
|
+
return default
|
135
|
+
end
|
135
136
|
|
136
|
-
|
137
|
-
|
138
|
-
|
137
|
+
if @config.stream? && !@stream_processor.started?
|
138
|
+
@stream_processor.start
|
139
|
+
end
|
139
140
|
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
diff = HashDiff.diff(feature, polled)
|
145
|
-
if not diff.empty?
|
146
|
-
@config.logger.error("Streamed flag differs from polled flag " + diff.to_s)
|
147
|
-
end
|
148
|
-
end
|
149
|
-
else
|
150
|
-
feature = get_flag_int(key)
|
151
|
-
end
|
152
|
-
value = evaluate(feature, user)
|
153
|
-
value.nil? ? default : value
|
154
|
-
|
155
|
-
add_event({:kind => 'feature', :key => key, :user => user, :value => value})
|
156
|
-
LDNewRelic.annotate_transaction(key, value)
|
157
|
-
return value
|
158
|
-
rescue StandardError => error
|
159
|
-
@config.logger.error("[LDClient] Unhandled exception in toggle: (#{error.class.name}) #{error.to_s}\n\t#{error.backtrace.join("\n\t")}")
|
160
|
-
default
|
141
|
+
if @config.stream? && @stream_processor.initialized?
|
142
|
+
feature = get_streamed_flag(key)
|
143
|
+
else
|
144
|
+
feature = get_flag_int(key)
|
161
145
|
end
|
146
|
+
value = evaluate(feature, user)
|
147
|
+
value = value.nil? ? default : value
|
148
|
+
|
149
|
+
add_event(kind: "feature", key: key, user: user, value: value)
|
150
|
+
LDNewRelic.annotate_transaction(key, value)
|
151
|
+
return value
|
152
|
+
rescue StandardError => error
|
153
|
+
log_exception(__method__.to_s, error)
|
154
|
+
default
|
162
155
|
end
|
163
156
|
|
164
157
|
def add_event(event)
|
165
|
-
if @offline
|
166
|
-
|
167
|
-
|
168
|
-
if @queue.length() < @config.capacity
|
158
|
+
return if @offline
|
159
|
+
|
160
|
+
if @queue.length < @config.capacity
|
169
161
|
event[:creationDate] = (Time.now.to_f * 1000).to_i
|
170
162
|
@queue.push(event)
|
171
163
|
|
172
164
|
if !@worker.alive?
|
173
|
-
@worker = create_worker
|
165
|
+
@worker = create_worker
|
174
166
|
end
|
175
167
|
else
|
176
168
|
@config.logger.warn("[LDClient] Exceeded event queue capacity. Increase capacity to avoid dropping events.")
|
177
169
|
end
|
178
170
|
end
|
179
171
|
|
180
|
-
#
|
172
|
+
#
|
181
173
|
# Registers the user
|
182
|
-
#
|
174
|
+
#
|
183
175
|
# @param [Hash] The user to register
|
184
|
-
#
|
176
|
+
#
|
185
177
|
def identify(user)
|
186
|
-
add_event(
|
178
|
+
add_event(kind: "identify", key: user[:key], user: user)
|
187
179
|
end
|
188
180
|
|
189
|
-
def set_offline
|
181
|
+
def set_offline
|
190
182
|
@offline = true
|
191
183
|
end
|
192
184
|
|
193
|
-
def set_online
|
185
|
+
def set_online
|
194
186
|
@offline = false
|
195
187
|
end
|
196
188
|
|
197
|
-
def is_offline?
|
198
|
-
|
189
|
+
def is_offline?
|
190
|
+
@offline
|
199
191
|
end
|
200
192
|
|
201
|
-
#
|
193
|
+
#
|
202
194
|
# Tracks that a user performed an event
|
203
|
-
#
|
195
|
+
#
|
204
196
|
# @param event_name [String] The name of the event
|
205
197
|
# @param user [Hash] The user that performed the event. This should be the same user hash used in calls to {#toggle?}
|
206
198
|
# @param data [Hash] A hash containing any additional data associated with the event
|
207
|
-
#
|
199
|
+
#
|
208
200
|
# @return [void]
|
209
201
|
def track(event_name, user, data)
|
210
|
-
add_event(
|
202
|
+
add_event(kind: "custom", key: event_name, user: user, data: data)
|
211
203
|
end
|
212
204
|
|
213
205
|
#
|
214
206
|
# Returns the key of every feature
|
215
207
|
#
|
216
208
|
def feature_keys
|
217
|
-
get_features.map {|feature| feature[:key]}
|
209
|
+
get_features.map { |feature| feature[:key] }
|
218
210
|
end
|
219
211
|
|
220
212
|
#
|
221
213
|
# Returns all features
|
222
214
|
#
|
223
215
|
def get_features
|
224
|
-
res =
|
225
|
-
req.headers['Authorization'] = 'api_key ' + @api_key
|
226
|
-
req.headers['User-Agent'] = 'RubyClient/' + LaunchDarkly::VERSION
|
227
|
-
req.options.timeout = @config.read_timeout
|
228
|
-
req.options.open_timeout = @config.connect_timeout
|
229
|
-
end
|
216
|
+
res = make_request "/api/features"
|
230
217
|
|
231
|
-
if res.status ==
|
218
|
+
if res.status / 100 == 2
|
232
219
|
return JSON.parse(res.body, symbolize_names: true)[:items]
|
233
220
|
else
|
234
221
|
@config.logger.error("[LDClient] Unexpected status code #{res.status}")
|
235
222
|
end
|
236
223
|
end
|
237
224
|
|
225
|
+
def get_streamed_flag(key)
|
226
|
+
feature = get_flag_stream(key)
|
227
|
+
if @config.debug_stream?
|
228
|
+
polled = get_flag_int(key)
|
229
|
+
diff = HashDiff.diff(feature, polled)
|
230
|
+
if not diff.empty?
|
231
|
+
@config.logger.error("Streamed flag differs from polled flag " + diff.to_s)
|
232
|
+
end
|
233
|
+
end
|
234
|
+
feature
|
235
|
+
end
|
236
|
+
|
238
237
|
def get_flag_stream(key)
|
239
238
|
@stream_processor.get_feature(key)
|
240
239
|
end
|
241
240
|
|
242
241
|
def get_flag_int(key)
|
243
|
-
res = log_timings("Feature request")
|
244
|
-
next
|
245
|
-
|
246
|
-
req.headers['User-Agent'] = 'RubyClient/' + LaunchDarkly::VERSION
|
247
|
-
req.options.timeout = @config.read_timeout
|
248
|
-
req.options.open_timeout = @config.connect_timeout
|
249
|
-
end
|
250
|
-
}
|
242
|
+
res = log_timings("Feature request") do
|
243
|
+
next make_request "/api/eval/features/" + key
|
244
|
+
end
|
251
245
|
|
252
246
|
if res.status == 401
|
253
247
|
@config.logger.error("[LDClient] Invalid API key")
|
@@ -259,71 +253,77 @@ module LaunchDarkly
|
|
259
253
|
return nil
|
260
254
|
end
|
261
255
|
|
262
|
-
if res.status !=
|
256
|
+
if res.status / 100 != 2
|
263
257
|
@config.logger.error("[LDClient] Unexpected status code #{res.status}")
|
264
258
|
return nil
|
265
259
|
end
|
266
260
|
|
261
|
+
JSON.parse(res.body, symbolize_names: true)
|
262
|
+
end
|
267
263
|
|
268
|
-
|
264
|
+
def make_request(path)
|
265
|
+
@client.get (@config.base_uri + path) do |req|
|
266
|
+
req.headers["Authorization"] = "api_key " + @api_key
|
267
|
+
req.headers["User-Agent"] = "RubyClient/" + LaunchDarkly::VERSION
|
268
|
+
req.options.timeout = @config.read_timeout
|
269
|
+
req.options.open_timeout = @config.connect_timeout
|
270
|
+
end
|
269
271
|
end
|
270
272
|
|
271
273
|
def param_for_user(feature, user)
|
272
|
-
|
273
|
-
id_hash = user[:key]
|
274
|
-
else
|
275
|
-
return nil
|
276
|
-
end
|
274
|
+
return nil unless user[:key]
|
277
275
|
|
278
|
-
|
279
|
-
|
276
|
+
id_hash = user[:key]
|
277
|
+
if user[:secondary]
|
278
|
+
id_hash += "." + user[:secondary]
|
280
279
|
end
|
281
280
|
|
282
281
|
hash_key = "%s.%s.%s" % [feature[:key], feature[:salt], id_hash]
|
283
282
|
|
284
283
|
hash_val = (Digest::SHA1.hexdigest(hash_key))[0..14]
|
285
|
-
|
284
|
+
hash_val.to_i(16) / Float(0xFFFFFFFFFFFFFFF)
|
286
285
|
end
|
287
286
|
|
288
287
|
def match_target?(target, user)
|
289
288
|
attrib = target[:attribute].to_sym
|
290
289
|
|
291
290
|
if BUILTINS.include?(attrib)
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
return false
|
297
|
-
end
|
291
|
+
return false unless user[attrib]
|
292
|
+
|
293
|
+
u_value = user[attrib]
|
294
|
+
return target[:values].include? u_value
|
298
295
|
else # custom attribute
|
299
|
-
unless
|
300
|
-
|
301
|
-
|
302
|
-
unless user[:custom].include? attrib
|
303
|
-
return false
|
304
|
-
end
|
296
|
+
return false unless user[:custom]
|
297
|
+
return false unless user[:custom].include? attrib
|
298
|
+
|
305
299
|
u_value = user[:custom][attrib]
|
306
300
|
if u_value.is_a? Array
|
307
|
-
return ! ((target[:values] & u_value).empty?)
|
301
|
+
return ! ((target[:values] & u_value).empty?)
|
308
302
|
else
|
309
303
|
return target[:values].include? u_value
|
310
304
|
end
|
311
305
|
|
312
|
-
return false
|
313
|
-
end
|
314
|
-
|
306
|
+
return false
|
307
|
+
end
|
315
308
|
end
|
316
309
|
|
317
310
|
def match_user?(variation, user)
|
318
|
-
if
|
311
|
+
if variation[:userTarget]
|
319
312
|
return match_target?(variation[:userTarget], user)
|
320
313
|
end
|
321
|
-
|
314
|
+
false
|
315
|
+
end
|
316
|
+
|
317
|
+
def find_user_match(feature, user)
|
318
|
+
feature[:variations].each do |variation|
|
319
|
+
return variation[:value] if match_user?(variation, user)
|
320
|
+
end
|
321
|
+
nil
|
322
322
|
end
|
323
323
|
|
324
324
|
def match_variation?(variation, user)
|
325
325
|
variation[:targets].each do |target|
|
326
|
-
if !!variation[:userTarget]
|
326
|
+
if !!variation[:userTarget] && target[:attribute].to_sym == :key
|
327
327
|
next
|
328
328
|
end
|
329
329
|
|
@@ -331,66 +331,68 @@ module LaunchDarkly
|
|
331
331
|
return true
|
332
332
|
end
|
333
333
|
end
|
334
|
-
|
334
|
+
false
|
335
335
|
end
|
336
336
|
|
337
|
-
def
|
338
|
-
if feature.nil? || !feature[:on]
|
339
|
-
return nil
|
340
|
-
end
|
341
|
-
|
342
|
-
param = param_for_user(feature, user)
|
343
|
-
|
344
|
-
if param.nil?
|
345
|
-
return nil
|
346
|
-
end
|
347
|
-
|
337
|
+
def find_target_match(feature, user)
|
348
338
|
feature[:variations].each do |variation|
|
349
|
-
if
|
350
|
-
return variation[:value]
|
351
|
-
end
|
352
|
-
end
|
353
|
-
|
354
|
-
feature[:variations].each do |variation|
|
355
|
-
if match_variation?(variation, user)
|
356
|
-
return variation[:value]
|
357
|
-
end
|
339
|
+
return variation[:value] if match_variation?(variation, user)
|
358
340
|
end
|
341
|
+
nil
|
342
|
+
end
|
359
343
|
|
344
|
+
def find_weight_match(feature, param)
|
360
345
|
total = 0.0
|
361
346
|
feature[:variations].each do |variation|
|
362
347
|
total += variation[:weight].to_f / 100.0
|
363
348
|
|
364
|
-
if param < total
|
365
|
-
return variation[:value]
|
366
|
-
end
|
349
|
+
return variation[:value] if param < total
|
367
350
|
end
|
368
351
|
|
369
|
-
|
352
|
+
nil
|
353
|
+
end
|
354
|
+
|
355
|
+
def evaluate(feature, user)
|
356
|
+
return nil if feature.nil?
|
357
|
+
return nil unless feature[:on]
|
358
|
+
|
359
|
+
param = param_for_user(feature, user)
|
360
|
+
return nil if param.nil?
|
361
|
+
|
362
|
+
value = find_user_match(feature, user)
|
363
|
+
return value if !value.nil?
|
364
|
+
|
365
|
+
value = find_target_match(feature, user)
|
366
|
+
return value if !value.nil?
|
370
367
|
|
368
|
+
find_weight_match(feature, param)
|
371
369
|
end
|
372
370
|
|
373
371
|
def log_timings(label, &block)
|
374
|
-
|
375
|
-
return block.call
|
376
|
-
end
|
372
|
+
return block.call unless @config.log_timings? && @config.logger.debug?
|
377
373
|
res = nil
|
378
374
|
exn = nil
|
379
|
-
bench = Benchmark.measure
|
375
|
+
bench = Benchmark.measure do
|
380
376
|
begin
|
381
377
|
res = block.call
|
382
|
-
rescue
|
378
|
+
rescue StandardError => e
|
383
379
|
exn = e
|
384
380
|
end
|
385
|
-
}
|
386
|
-
@config.logger.debug { "[LDClient] #{label} timing: #{bench}".chomp }
|
387
|
-
if exn
|
388
|
-
raise exn
|
389
381
|
end
|
390
|
-
|
382
|
+
@config.logger.debug { "[LDClient] #{label} timing: #{bench}".chomp }
|
383
|
+
raise exn if exn
|
384
|
+
res
|
391
385
|
end
|
392
386
|
|
393
|
-
|
387
|
+
def log_exception(caller, exn)
|
388
|
+
error_traceback = "#{exn.inspect} #{exn}\n\t#{exn.backtrace.join("\n\t")}"
|
389
|
+
error = "[LDClient] Unexpected exception in #{caller}: #{error_traceback}"
|
390
|
+
@config.logger.error(error)
|
391
|
+
end
|
394
392
|
|
393
|
+
private :post_flushed_events, :add_event, :get_streamed_flag,
|
394
|
+
:get_flag_stream, :get_flag_int, :make_request, :param_for_user,
|
395
|
+
:match_target?, :match_user?, :match_variation?, :evaluate,
|
396
|
+
:create_worker, :log_timings, :log_exception
|
395
397
|
end
|
396
|
-
end
|
398
|
+
end
|