statsig 1.25.1 → 1.26.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/lib/client_initialize_helpers.rb +29 -3
- data/lib/diagnostics.rb +82 -15
- data/lib/error_boundary.rb +41 -34
- data/lib/evaluator.rb +6 -5
- data/lib/hash_utils.rb +17 -0
- data/lib/network.rb +57 -46
- data/lib/spec_store.rb +109 -64
- data/lib/statsig.rb +6 -4
- data/lib/statsig_driver.rb +106 -84
- data/lib/statsig_errors.rb +1 -0
- data/lib/statsig_logger.rb +25 -15
- data/lib/statsig_options.rb +8 -0
- data/lib/ua_parser.rb +1 -0
- data/lib/uri_helper.rb +37 -0
- metadata +52 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 42f5328314de8727a2baaf89da54999627a5505ad95cd5ab816877741e02dd55
|
4
|
+
data.tar.gz: f6af1d20f540ceae4e24c70bdc702fbd9db3acc38776b71ecc50ded6ef40f67d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8c4375a325bf7dd1afaa584951b31eca2fd19422301b72397729fdca89e1ef8ae9b7a979aff562889cf49fc98fcd7db3edfc022ca66eb063b2680645c90df2db
|
7
|
+
data.tar.gz: ab23702707825b1ed5ada0df1982d765b97b75e8d813aeeaa9f24ae31974bb0cafa58249e82cc041eaa845bea9ee7639189c8778f8e21e0908b7ec3174d41661
|
@@ -1,4 +1,7 @@
|
|
1
1
|
# typed: true
|
2
|
+
|
3
|
+
require_relative 'hash_utils'
|
4
|
+
|
2
5
|
$empty_eval_result = {
|
3
6
|
:gate_value => false,
|
4
7
|
:json_value => {},
|
@@ -9,10 +12,12 @@ $empty_eval_result = {
|
|
9
12
|
|
10
13
|
module ClientInitializeHelpers
|
11
14
|
class ResponseFormatter
|
12
|
-
def initialize(evaluator, user)
|
15
|
+
def initialize(evaluator, user, hash, client_sdk_key)
|
13
16
|
@evaluator = evaluator
|
14
17
|
@user = user
|
15
18
|
@specs = evaluator.spec_store.get_raw_specs
|
19
|
+
@hash = hash
|
20
|
+
@client_sdk_key = client_sdk_key
|
16
21
|
end
|
17
22
|
|
18
23
|
def get_responses(key)
|
@@ -24,6 +29,13 @@ module ClientInitializeHelpers
|
|
24
29
|
private
|
25
30
|
|
26
31
|
def to_response(config_name, config_spec)
|
32
|
+
target_app_id = @evaluator.spec_store.get_app_id_for_sdk_key(@client_sdk_key)
|
33
|
+
config_target_apps = config_spec['targetAppIDs']
|
34
|
+
|
35
|
+
unless target_app_id.nil? || config_target_apps.nil? || config_target_apps.include?(target_app_id)
|
36
|
+
return nil
|
37
|
+
end
|
38
|
+
|
27
39
|
eval_result = @evaluator.eval_spec(@user, config_spec)
|
28
40
|
if eval_result.nil?
|
29
41
|
return nil
|
@@ -33,6 +45,8 @@ module ClientInitializeHelpers
|
|
33
45
|
:gate_value => eval_result.gate_value,
|
34
46
|
:json_value => eval_result.json_value,
|
35
47
|
:rule_id => eval_result.rule_id,
|
48
|
+
:group_name => eval_result.group_name,
|
49
|
+
:id_type => eval_result.id_type,
|
36
50
|
:config_delegate => eval_result.config_delegate,
|
37
51
|
:is_experiment_group => eval_result.is_experiment_group,
|
38
52
|
:secondary_exposures => eval_result.secondary_exposures,
|
@@ -52,10 +66,14 @@ module ClientInitializeHelpers
|
|
52
66
|
end
|
53
67
|
|
54
68
|
result['value'] = safe_eval_result[:gate_value]
|
69
|
+
result["group_name"] = safe_eval_result[:group_name]
|
70
|
+
result["id_type"] = safe_eval_result[:id_type]
|
55
71
|
when 'dynamic_config'
|
56
72
|
id_type = config_spec['idType']
|
57
73
|
result['value'] = safe_eval_result[:json_value]
|
58
74
|
result["group"] = safe_eval_result[:rule_id]
|
75
|
+
result["group_name"] = safe_eval_result[:group_name]
|
76
|
+
result["id_type"] = safe_eval_result[:id_type]
|
59
77
|
result["is_device_based"] = id_type.is_a?(String) && id_type.downcase == 'stableid'
|
60
78
|
else
|
61
79
|
return nil
|
@@ -67,6 +85,7 @@ module ClientInitializeHelpers
|
|
67
85
|
|
68
86
|
if entity_type == 'layer'
|
69
87
|
populate_layer_fields(config_spec, safe_eval_result, result)
|
88
|
+
result.delete('id_type') # not exposed for layer configs in /initialize
|
70
89
|
end
|
71
90
|
|
72
91
|
hashed_name = hash_name(config_name)
|
@@ -126,7 +145,14 @@ module ClientInitializeHelpers
|
|
126
145
|
end
|
127
146
|
|
128
147
|
def hash_name(name)
|
129
|
-
|
148
|
+
case @hash
|
149
|
+
when 'none'
|
150
|
+
return name
|
151
|
+
when 'sha256'
|
152
|
+
return Statsig::HashUtils.sha256(name)
|
153
|
+
when 'djb2'
|
154
|
+
return Statsig::HashUtils.djb2(name)
|
155
|
+
end
|
130
156
|
end
|
131
157
|
end
|
132
|
-
end
|
158
|
+
end
|
data/lib/diagnostics.rb
CHANGED
@@ -12,33 +12,100 @@ module Statsig
|
|
12
12
|
sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
|
13
13
|
attr_reader :markers
|
14
14
|
|
15
|
-
sig { params(context: String).void }
|
16
|
-
|
17
15
|
def initialize(context)
|
18
16
|
@context = context
|
19
17
|
@markers = []
|
20
18
|
end
|
21
19
|
|
22
|
-
sig
|
20
|
+
sig do
|
21
|
+
params(
|
22
|
+
key: String,
|
23
|
+
action: String,
|
24
|
+
step: T.any(String, NilClass),
|
25
|
+
tags: T::Hash[Symbol, T.untyped]
|
26
|
+
).void
|
27
|
+
end
|
28
|
+
|
29
|
+
def mark(key, action, step, tags)
|
30
|
+
marker = {
|
31
|
+
key: key,
|
32
|
+
action: action,
|
33
|
+
timestamp: (Time.now.to_f * 1000).to_i
|
34
|
+
}
|
35
|
+
if !step.nil?
|
36
|
+
marker[:step] = step
|
37
|
+
end
|
38
|
+
tags.each do |key, val|
|
39
|
+
unless val.nil?
|
40
|
+
marker[key] = val
|
41
|
+
end
|
42
|
+
end
|
43
|
+
@markers.push(marker)
|
44
|
+
end
|
23
45
|
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
46
|
+
sig do
|
47
|
+
params(
|
48
|
+
key: String,
|
49
|
+
step: T.any(String, NilClass),
|
50
|
+
tags: T::Hash[Symbol, T.untyped]
|
51
|
+
).returns(Tracker)
|
52
|
+
end
|
53
|
+
def track(key, step = nil, tags = {})
|
54
|
+
tracker = Tracker.new(self, key, step, tags)
|
55
|
+
tracker.start(**tags)
|
56
|
+
tracker
|
32
57
|
end
|
33
58
|
|
34
59
|
sig { returns(T::Hash[Symbol, T.untyped]) }
|
35
60
|
|
36
61
|
def serialize
|
37
62
|
{
|
38
|
-
context: @context,
|
39
|
-
markers: @markers
|
63
|
+
context: @context.clone,
|
64
|
+
markers: @markers.clone
|
40
65
|
}
|
41
66
|
end
|
42
|
-
end
|
43
67
|
|
44
|
-
|
68
|
+
def clear_markers
|
69
|
+
@markers.clear
|
70
|
+
end
|
71
|
+
|
72
|
+
def self.sample(rate)
|
73
|
+
rand(rate).zero?
|
74
|
+
end
|
75
|
+
|
76
|
+
class Context
|
77
|
+
INITIALIZE = 'initialize'.freeze
|
78
|
+
CONFIG_SYNC = 'config_sync'.freeze
|
79
|
+
API_CALL = 'api_call'.freeze
|
80
|
+
end
|
81
|
+
|
82
|
+
API_CALL_KEYS = %w[check_gate get_config get_experiment get_layer].freeze
|
83
|
+
|
84
|
+
class Tracker
|
85
|
+
extend T::Sig
|
86
|
+
|
87
|
+
sig do
|
88
|
+
params(
|
89
|
+
diagnostics: Diagnostics,
|
90
|
+
key: String,
|
91
|
+
step: T.any(String, NilClass),
|
92
|
+
tags: T::Hash[Symbol, T.untyped]
|
93
|
+
).void
|
94
|
+
end
|
95
|
+
def initialize(diagnostics, key, step, tags = {})
|
96
|
+
@diagnostics = diagnostics
|
97
|
+
@key = key
|
98
|
+
@step = step
|
99
|
+
@tags = tags
|
100
|
+
end
|
101
|
+
|
102
|
+
def start(**tags)
|
103
|
+
@diagnostics.mark(@key, 'start', @step, tags.nil? ? {} : tags.merge(@tags))
|
104
|
+
end
|
105
|
+
|
106
|
+
def end(**tags)
|
107
|
+
@diagnostics.mark(@key, 'end', @step, tags.nil? ? {} : tags.merge(@tags))
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
data/lib/error_boundary.rb
CHANGED
@@ -1,57 +1,64 @@
|
|
1
|
-
|
1
|
+
# typed: true
|
2
|
+
|
3
|
+
require 'statsig_errors'
|
4
|
+
require 'sorbet-runtime'
|
2
5
|
|
3
6
|
$endpoint = 'https://statsigapi.net/v1/sdk_exception'
|
4
7
|
|
5
8
|
module Statsig
|
6
9
|
class ErrorBoundary
|
10
|
+
extend T::Sig
|
11
|
+
|
12
|
+
sig { params(sdk_key: String).void }
|
7
13
|
def initialize(sdk_key)
|
8
14
|
@sdk_key = sdk_key
|
9
15
|
@seen = Set.new
|
10
16
|
end
|
11
17
|
|
12
|
-
def capture(task
|
18
|
+
def capture(task:, recover: -> {}, caller: nil)
|
13
19
|
begin
|
14
|
-
|
20
|
+
res = task.call
|
15
21
|
rescue StandardError => e
|
16
|
-
if e.is_a?(Statsig::UninitializedError)
|
22
|
+
if e.is_a?(Statsig::UninitializedError) || e.is_a?(Statsig::ValueError)
|
17
23
|
raise e
|
18
24
|
end
|
19
|
-
|
20
|
-
|
21
|
-
|
25
|
+
|
26
|
+
puts '[Statsig]: An unexpected exception occurred.'
|
27
|
+
log_exception(e, tag: caller)
|
28
|
+
res = recover.call
|
22
29
|
end
|
30
|
+
return res
|
23
31
|
end
|
24
32
|
|
25
33
|
private
|
26
34
|
|
27
|
-
def log_exception(exception)
|
28
|
-
|
29
|
-
|
30
|
-
if @seen.include?(name)
|
31
|
-
return
|
32
|
-
end
|
33
|
-
|
34
|
-
@seen << name
|
35
|
-
meta = Statsig.get_statsig_metadata
|
36
|
-
http = HTTP.headers(
|
37
|
-
{
|
38
|
-
"STATSIG-API-KEY" => @sdk_key,
|
39
|
-
"STATSIG-SDK-TYPE" => meta['sdkType'],
|
40
|
-
"STATSIG-SDK-VERSION" => meta['sdkVersion'],
|
41
|
-
"Content-Type" => "application/json; charset=UTF-8"
|
42
|
-
}).accept(:json)
|
43
|
-
body = {
|
44
|
-
"exception" => name,
|
45
|
-
"info" => {
|
46
|
-
"trace" => exception.backtrace.to_s,
|
47
|
-
"message" => exception.message
|
48
|
-
}.to_s,
|
49
|
-
"statsigMetadata" => meta
|
50
|
-
}
|
51
|
-
http.post($endpoint, body: JSON.generate(body))
|
52
|
-
rescue
|
35
|
+
def log_exception(exception, tag: nil)
|
36
|
+
name = exception.class.name
|
37
|
+
if @seen.include?(name)
|
53
38
|
return
|
54
39
|
end
|
40
|
+
|
41
|
+
@seen << name
|
42
|
+
meta = Statsig.get_statsig_metadata
|
43
|
+
http = HTTP.headers(
|
44
|
+
{
|
45
|
+
'STATSIG-API-KEY' => @sdk_key,
|
46
|
+
'STATSIG-SDK-TYPE' => meta['sdkType'],
|
47
|
+
'STATSIG-SDK-VERSION' => meta['sdkVersion'],
|
48
|
+
'Content-Type' => 'application/json; charset=UTF-8'
|
49
|
+
}).accept(:json)
|
50
|
+
body = {
|
51
|
+
'exception' => name,
|
52
|
+
'info' => {
|
53
|
+
'trace' => exception.backtrace.to_s,
|
54
|
+
'message' => exception.message
|
55
|
+
}.to_s,
|
56
|
+
'statsigMetadata' => meta,
|
57
|
+
'tag' => tag
|
58
|
+
}
|
59
|
+
http.post($endpoint, body: JSON.generate(body))
|
60
|
+
rescue StandardError
|
61
|
+
return
|
55
62
|
end
|
56
63
|
end
|
57
|
-
end
|
64
|
+
end
|
data/lib/evaluator.rb
CHANGED
@@ -17,8 +17,8 @@ module Statsig
|
|
17
17
|
class Evaluator
|
18
18
|
attr_accessor :spec_store
|
19
19
|
|
20
|
-
def initialize(network, options, error_callback,
|
21
|
-
@spec_store = Statsig::SpecStore.new(network, options, error_callback,
|
20
|
+
def initialize(network, options, error_callback, diagnostics, error_boundary, logger)
|
21
|
+
@spec_store = Statsig::SpecStore.new(network, options, error_callback, diagnostics, error_boundary, logger)
|
22
22
|
UAParser.initialize_async
|
23
23
|
CountryLookup.initialize_async
|
24
24
|
|
@@ -98,12 +98,12 @@ module Statsig
|
|
98
98
|
eval_spec(user, @spec_store.get_layer(layer_name))
|
99
99
|
end
|
100
100
|
|
101
|
-
def get_client_initialize_response(user)
|
101
|
+
def get_client_initialize_response(user, hash, client_sdk_key)
|
102
102
|
if @spec_store.is_ready_for_checks == false
|
103
103
|
return nil
|
104
104
|
end
|
105
105
|
|
106
|
-
formatter = ClientInitializeHelpers::ResponseFormatter.new(self, user)
|
106
|
+
formatter = ClientInitializeHelpers::ResponseFormatter.new(self, user, hash, client_sdk_key)
|
107
107
|
|
108
108
|
evaluated_keys = {}
|
109
109
|
if user.user_id.nil? == false
|
@@ -123,6 +123,7 @@ module Statsig
|
|
123
123
|
"generator" => "statsig-ruby-sdk",
|
124
124
|
"evaluated_keys" => evaluated_keys,
|
125
125
|
"time" => 0,
|
126
|
+
"hash_used" => hash
|
126
127
|
}
|
127
128
|
end
|
128
129
|
|
@@ -199,7 +200,7 @@ module Statsig
|
|
199
200
|
@spec_store.initial_config_sync_time,
|
200
201
|
@spec_store.init_reason
|
201
202
|
),
|
202
|
-
group_name:
|
203
|
+
group_name: 'default',
|
203
204
|
id_type: config['idType']
|
204
205
|
)
|
205
206
|
end
|
data/lib/hash_utils.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
module Statsig
|
2
|
+
class HashUtils
|
3
|
+
def self.djb2(input_str)
|
4
|
+
hash = 0
|
5
|
+
input_str.each_char.each do |c|
|
6
|
+
hash = (hash << 5) - hash + c.ord
|
7
|
+
hash &= hash
|
8
|
+
end
|
9
|
+
hash &= 0xFFFFFFFF # Convert to unsigned 32-bit integer
|
10
|
+
return hash.to_s
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.sha256(input_str)
|
14
|
+
return Digest::SHA256.base64digest(input_str)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
data/lib/network.rb
CHANGED
@@ -4,8 +4,10 @@ require 'http'
|
|
4
4
|
require 'json'
|
5
5
|
require 'securerandom'
|
6
6
|
require 'sorbet-runtime'
|
7
|
+
require 'uri_helper'
|
8
|
+
require 'connection_pool'
|
7
9
|
|
8
|
-
|
10
|
+
RETRY_CODES = [408, 500, 502, 503, 504, 522, 524, 599].freeze
|
9
11
|
|
10
12
|
module Statsig
|
11
13
|
class NetworkError < StandardError
|
@@ -24,41 +26,44 @@ module Statsig
|
|
24
26
|
|
25
27
|
def initialize(server_secret, options, backoff_mult = 10)
|
26
28
|
super()
|
27
|
-
|
28
|
-
unless api.end_with?('/')
|
29
|
-
api += '/'
|
30
|
-
end
|
29
|
+
URIHelper.initialize(options)
|
31
30
|
@server_secret = server_secret
|
32
|
-
@api = api
|
33
31
|
@local_mode = options.local_mode
|
34
32
|
@timeout = options.network_timeout
|
35
33
|
@backoff_multiplier = backoff_mult
|
36
34
|
@post_logs_retry_backoff = options.post_logs_retry_backoff
|
37
35
|
@post_logs_retry_limit = options.post_logs_retry_limit
|
38
36
|
@session_id = SecureRandom.uuid
|
37
|
+
@connection_pool = ConnectionPool.new(size: 3) do
|
38
|
+
meta = Statsig.get_statsig_metadata
|
39
|
+
client = HTTP.headers(
|
40
|
+
{
|
41
|
+
'STATSIG-API-KEY' => @server_secret,
|
42
|
+
'STATSIG-CLIENT-TIME' => (Time.now.to_f * 1000).to_i.to_s,
|
43
|
+
'STATSIG-SERVER-SESSION-ID' => @session_id,
|
44
|
+
'Content-Type' => 'application/json; charset=UTF-8',
|
45
|
+
'STATSIG-SDK-TYPE' => meta['sdkType'],
|
46
|
+
'STATSIG-SDK-VERSION' => meta['sdkVersion']
|
47
|
+
}
|
48
|
+
).accept(:json)
|
49
|
+
if @timeout
|
50
|
+
client = client.timeout(@timeout)
|
51
|
+
end
|
52
|
+
|
53
|
+
client
|
54
|
+
end
|
39
55
|
end
|
40
56
|
|
41
|
-
sig
|
42
|
-
|
57
|
+
sig do
|
58
|
+
params(endpoint: String, body: String, retries: Integer, backoff: Integer)
|
59
|
+
.returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)])
|
60
|
+
end
|
43
61
|
|
44
62
|
def post_helper(endpoint, body, retries = 0, backoff = 1)
|
45
63
|
if @local_mode
|
46
64
|
return nil, nil
|
47
65
|
end
|
48
66
|
|
49
|
-
meta = Statsig.get_statsig_metadata
|
50
|
-
http = HTTP.headers(
|
51
|
-
{
|
52
|
-
"STATSIG-API-KEY" => @server_secret,
|
53
|
-
"STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_i.to_s,
|
54
|
-
"STATSIG-SERVER-SESSION-ID" => @session_id,
|
55
|
-
"Content-Type" => "application/json; charset=UTF-8",
|
56
|
-
"STATSIG-SDK-TYPE" => meta['sdkType'],
|
57
|
-
"STATSIG-SDK-VERSION" => meta['sdkVersion'],
|
58
|
-
}).accept(:json)
|
59
|
-
if @timeout
|
60
|
-
http = http.timeout(@timeout)
|
61
|
-
end
|
62
67
|
backoff_adjusted = backoff > 10 ? backoff += Random.rand(10) : backoff # to deter overlap
|
63
68
|
if @post_logs_retry_backoff
|
64
69
|
if @post_logs_retry_backoff.is_a? Integer
|
@@ -67,49 +72,55 @@ module Statsig
|
|
67
72
|
backoff_adjusted = @post_logs_retry_backoff.call(retries)
|
68
73
|
end
|
69
74
|
end
|
75
|
+
url = URIHelper.build_url(endpoint)
|
70
76
|
begin
|
71
|
-
res =
|
77
|
+
res = @connection_pool.with do |conn|
|
78
|
+
conn.headers('STATSIG-CLIENT-TIME' => (Time.now.to_f * 1000).to_i.to_s).post(url, body: body)
|
79
|
+
end
|
72
80
|
rescue StandardError => e
|
73
81
|
## network error retry
|
74
|
-
return nil, e unless retries
|
82
|
+
return nil, e unless retries.positive?
|
83
|
+
|
75
84
|
sleep backoff_adjusted
|
76
85
|
return post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
|
77
86
|
end
|
78
87
|
return res, nil if res.status.success?
|
79
|
-
|
88
|
+
|
89
|
+
unless retries.positive? && RETRY_CODES.include?(res.code)
|
90
|
+
return res, NetworkError.new("Got an exception when making request to #{url}: #{res.to_s}",
|
91
|
+
res.status.to_i)
|
92
|
+
end
|
93
|
+
|
80
94
|
## status code retry
|
81
95
|
sleep backoff_adjusted
|
82
96
|
post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
|
83
97
|
end
|
84
98
|
|
85
99
|
def check_gate(user, gate_name)
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
end
|
100
|
+
request_body = JSON.generate({ 'user' => user&.serialize(false), 'gateName' => gate_name })
|
101
|
+
response, = post_helper('check_gate', request_body)
|
102
|
+
return JSON.parse(response.body) unless response.nil?
|
103
|
+
|
104
|
+
false
|
105
|
+
rescue StandardError
|
106
|
+
false
|
94
107
|
end
|
95
108
|
|
96
109
|
def get_config(user, dynamic_config_name)
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
end
|
110
|
+
request_body = JSON.generate({ 'user' => user&.serialize(false), 'configName' => dynamic_config_name })
|
111
|
+
response, = post_helper('get_config', request_body)
|
112
|
+
return JSON.parse(response.body) unless response.nil?
|
113
|
+
|
114
|
+
nil
|
115
|
+
rescue StandardError
|
116
|
+
nil
|
105
117
|
end
|
106
118
|
|
107
119
|
def post_logs(events)
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
end
|
120
|
+
json_body = JSON.generate({ 'events' => events, 'statsigMetadata' => Statsig.get_statsig_metadata })
|
121
|
+
post_helper('log_event', json_body, @post_logs_retry_limit)
|
122
|
+
rescue StandardError
|
123
|
+
|
113
124
|
end
|
114
125
|
end
|
115
|
-
end
|
126
|
+
end
|