segment 2.2.5
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/Gemfile +2 -0
- data/Gemfile.lock +89 -0
- data/History.md +222 -0
- data/Makefile +17 -0
- data/README.md +84 -0
- data/RELEASING.md +9 -0
- data/Rakefile +23 -0
- data/analytics-ruby.gemspec +33 -0
- data/bin/analytics +93 -0
- data/codecov.yml +2 -0
- data/lib/analytics-ruby.rb +1 -0
- data/lib/segment.rb +1 -0
- data/lib/segment/analytics.rb +38 -0
- data/lib/segment/analytics/backoff_policy.rb +49 -0
- data/lib/segment/analytics/client.rb +425 -0
- data/lib/segment/analytics/defaults.rb +36 -0
- data/lib/segment/analytics/logging.rb +33 -0
- data/lib/segment/analytics/message.rb +26 -0
- data/lib/segment/analytics/message_batch.rb +59 -0
- data/lib/segment/analytics/request.rb +134 -0
- data/lib/segment/analytics/response.rb +15 -0
- data/lib/segment/analytics/utils.rb +91 -0
- data/lib/segment/analytics/version.rb +5 -0
- data/lib/segment/analytics/worker.rb +61 -0
- data/spec/helpers/runscope_client.rb +38 -0
- data/spec/segment/analytics/backoff_policy_spec.rb +92 -0
- data/spec/segment/analytics/client_spec.rb +328 -0
- data/spec/segment/analytics/e2e_spec.rb +48 -0
- data/spec/segment/analytics/message_batch_spec.rb +49 -0
- data/spec/segment/analytics/message_spec.rb +35 -0
- data/spec/segment/analytics/request_spec.rb +244 -0
- data/spec/segment/analytics/response_spec.rb +30 -0
- data/spec/segment/analytics/worker_spec.rb +110 -0
- data/spec/segment/analytics_spec.rb +120 -0
- data/spec/spec_helper.rb +128 -0
- metadata +205 -0
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module Segment
|
4
|
+
# End-to-end tests that send events to a segment source and verifies that a
|
5
|
+
# webhook connected to the source (configured manually via the app) is able
|
6
|
+
# to receive the data sent by this library.
|
7
|
+
describe 'End-to-end tests', e2e: true do
|
8
|
+
# Segment write key for
|
9
|
+
# https://app.segment.com/segment-libraries/sources/analytics_ruby_e2e_test/overview.
|
10
|
+
#
|
11
|
+
# This source is configured to send events to the Runscope bucket used by
|
12
|
+
# this test.
|
13
|
+
WRITE_KEY = 'qhdMksLsQTi9MES3CHyzsWRRt4ub5VM6'
|
14
|
+
|
15
|
+
# Runscope bucket key for https://www.runscope.com/stream/umkvkgv7ndby
|
16
|
+
RUNSCOPE_BUCKET_KEY = 'umkvkgv7ndby'
|
17
|
+
|
18
|
+
let(:client) { Segment::Analytics.new(write_key: WRITE_KEY) }
|
19
|
+
let(:runscope_client) { RunscopeClient.new(ENV.fetch('RUNSCOPE_TOKEN')) }
|
20
|
+
|
21
|
+
it 'tracks events' do
|
22
|
+
id = SecureRandom.uuid
|
23
|
+
client.track(
|
24
|
+
user_id: 'dummy_user_id',
|
25
|
+
event: 'E2E Test',
|
26
|
+
properties: { id: id }
|
27
|
+
)
|
28
|
+
client.flush
|
29
|
+
|
30
|
+
# Allow events to propagate to runscope
|
31
|
+
eventually(timeout: 30) {
|
32
|
+
expect(has_matching_request?(id)).to eq(true)
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
def has_matching_request?(id)
|
37
|
+
captured_requests = runscope_client.requests(RUNSCOPE_BUCKET_KEY)
|
38
|
+
captured_requests.any? do |request|
|
39
|
+
begin
|
40
|
+
body = JSON.parse(request['body'])
|
41
|
+
body['properties'] && body['properties']['id'] == id
|
42
|
+
rescue JSON::ParserError
|
43
|
+
false
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module Segment
|
4
|
+
class Analytics
|
5
|
+
describe MessageBatch do
|
6
|
+
subject { described_class.new(100) }
|
7
|
+
|
8
|
+
describe '#<<' do
|
9
|
+
it 'appends messages' do
|
10
|
+
subject << Message.new('a' => 'b')
|
11
|
+
expect(subject.length).to eq(1)
|
12
|
+
end
|
13
|
+
|
14
|
+
it 'rejects messages that exceed the maximum allowed size' do
|
15
|
+
max_bytes = Defaults::Message::MAX_BYTES
|
16
|
+
hash = { 'a' => 'b' * max_bytes }
|
17
|
+
message = Message.new(hash)
|
18
|
+
|
19
|
+
subject << message
|
20
|
+
expect(subject.length).to eq(0)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
describe '#full?' do
|
25
|
+
it 'returns true once item count is exceeded' do
|
26
|
+
99.times { subject << Message.new(a: 'b') }
|
27
|
+
expect(subject.full?).to be(false)
|
28
|
+
|
29
|
+
subject << Message.new(a: 'b')
|
30
|
+
expect(subject.full?).to be(true)
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'returns true once max size is almost exceeded' do
|
34
|
+
message = Message.new(a: 'b' * (Defaults::Message::MAX_BYTES - 10))
|
35
|
+
|
36
|
+
# Each message is under the individual limit
|
37
|
+
expect(message.json_size).to be < Defaults::Message::MAX_BYTES
|
38
|
+
|
39
|
+
# Size of the batch is over the limit
|
40
|
+
expect(50 * message.json_size).to be > Defaults::MessageBatch::MAX_BYTES
|
41
|
+
|
42
|
+
expect(subject.full?).to be(false)
|
43
|
+
50.times { subject << message }
|
44
|
+
expect(subject.full?).to be(true)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module Segment
|
4
|
+
class Analytics
|
5
|
+
describe Message do
|
6
|
+
describe '#to_json' do
|
7
|
+
it 'caches JSON conversions' do
|
8
|
+
# Keeps track of the number of times to_json was called
|
9
|
+
nested_obj = Class.new do
|
10
|
+
attr_reader :to_json_call_count
|
11
|
+
|
12
|
+
def initialize
|
13
|
+
@to_json_call_count = 0
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_json(*_)
|
17
|
+
@to_json_call_count += 1
|
18
|
+
'{}'
|
19
|
+
end
|
20
|
+
end.new
|
21
|
+
|
22
|
+
message = Message.new('some_key' => nested_obj)
|
23
|
+
expect(nested_obj.to_json_call_count).to eq(0)
|
24
|
+
|
25
|
+
message.to_json
|
26
|
+
expect(nested_obj.to_json_call_count).to eq(1)
|
27
|
+
|
28
|
+
# When called a second time, the call count shouldn't increase
|
29
|
+
message.to_json
|
30
|
+
expect(nested_obj.to_json_call_count).to eq(1)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,244 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module Segment
|
4
|
+
class Analytics
|
5
|
+
describe Request do
|
6
|
+
before do
|
7
|
+
# Try and keep debug statements out of tests
|
8
|
+
allow(subject.logger).to receive(:error)
|
9
|
+
allow(subject.logger).to receive(:debug)
|
10
|
+
end
|
11
|
+
|
12
|
+
describe '#initialize' do
|
13
|
+
let!(:net_http) { Net::HTTP.new(anything, anything) }
|
14
|
+
|
15
|
+
before do
|
16
|
+
allow(Net::HTTP).to receive(:new) { net_http }
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'sets an initalized Net::HTTP read_timeout' do
|
20
|
+
expect(net_http).to receive(:use_ssl=)
|
21
|
+
described_class.new
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'sets an initalized Net::HTTP read_timeout' do
|
25
|
+
expect(net_http).to receive(:read_timeout=)
|
26
|
+
described_class.new
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'sets an initalized Net::HTTP open_timeout' do
|
30
|
+
expect(net_http).to receive(:open_timeout=)
|
31
|
+
described_class.new
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'sets the http client' do
|
35
|
+
expect(subject.instance_variable_get(:@http)).to_not be_nil
|
36
|
+
end
|
37
|
+
|
38
|
+
context 'no options are set' do
|
39
|
+
it 'sets a default path' do
|
40
|
+
path = subject.instance_variable_get(:@path)
|
41
|
+
expect(path).to eq(described_class::PATH)
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'sets a default retries' do
|
45
|
+
retries = subject.instance_variable_get(:@retries)
|
46
|
+
expect(retries).to eq(described_class::RETRIES)
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'sets a default backoff policy' do
|
50
|
+
backoff_policy = subject.instance_variable_get(:@backoff_policy)
|
51
|
+
expect(backoff_policy).to be_a(Segment::Analytics::BackoffPolicy)
|
52
|
+
end
|
53
|
+
|
54
|
+
it 'initializes a new Net::HTTP with default host and port' do
|
55
|
+
expect(Net::HTTP).to receive(:new).with(
|
56
|
+
described_class::HOST,
|
57
|
+
described_class::PORT
|
58
|
+
)
|
59
|
+
described_class.new
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
context 'options are given' do
|
64
|
+
let(:path) { 'my/cool/path' }
|
65
|
+
let(:retries) { 1234 }
|
66
|
+
let(:backoff_policy) { FakeBackoffPolicy.new([1, 2, 3]) }
|
67
|
+
let(:host) { 'http://www.example.com' }
|
68
|
+
let(:port) { 8080 }
|
69
|
+
let(:options) do
|
70
|
+
{
|
71
|
+
path: path,
|
72
|
+
retries: retries,
|
73
|
+
backoff_policy: backoff_policy,
|
74
|
+
host: host,
|
75
|
+
port: port
|
76
|
+
}
|
77
|
+
end
|
78
|
+
|
79
|
+
subject { described_class.new(options) }
|
80
|
+
|
81
|
+
it 'sets passed in path' do
|
82
|
+
expect(subject.instance_variable_get(:@path)).to eq(path)
|
83
|
+
end
|
84
|
+
|
85
|
+
it 'sets passed in retries' do
|
86
|
+
expect(subject.instance_variable_get(:@retries)).to eq(retries)
|
87
|
+
end
|
88
|
+
|
89
|
+
it 'sets passed in backoff backoff policy' do
|
90
|
+
expect(subject.instance_variable_get(:@backoff_policy))
|
91
|
+
.to eq(backoff_policy)
|
92
|
+
end
|
93
|
+
|
94
|
+
it 'initializes a new Net::HTTP with passed in host and port' do
|
95
|
+
expect(Net::HTTP).to receive(:new).with(host, port)
|
96
|
+
described_class.new(options)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
describe '#post' do
|
102
|
+
let(:response) {
|
103
|
+
Net::HTTPResponse.new(http_version, status_code, response_body)
|
104
|
+
}
|
105
|
+
let(:http_version) { 1.1 }
|
106
|
+
let(:status_code) { 200 }
|
107
|
+
let(:response_body) { {}.to_json }
|
108
|
+
let(:write_key) { 'abcdefg' }
|
109
|
+
let(:batch) { [] }
|
110
|
+
|
111
|
+
before do
|
112
|
+
http = subject.instance_variable_get(:@http)
|
113
|
+
allow(http).to receive(:request) { response }
|
114
|
+
allow(response).to receive(:body) { response_body }
|
115
|
+
end
|
116
|
+
|
117
|
+
it 'initalizes a new Net::HTTP::Post with path and default headers' do
|
118
|
+
path = subject.instance_variable_get(:@path)
|
119
|
+
default_headers = {
|
120
|
+
'Content-Type' => 'application/json',
|
121
|
+
'Accept' => 'application/json',
|
122
|
+
'User-Agent' => "analytics-ruby/#{Analytics::VERSION}"
|
123
|
+
}
|
124
|
+
expect(Net::HTTP::Post).to receive(:new).with(
|
125
|
+
path, default_headers
|
126
|
+
).and_call_original
|
127
|
+
|
128
|
+
subject.post(write_key, batch)
|
129
|
+
end
|
130
|
+
|
131
|
+
it 'adds basic auth to the Net::HTTP::Post' do
|
132
|
+
expect_any_instance_of(Net::HTTP::Post).to receive(:basic_auth)
|
133
|
+
.with(write_key, nil)
|
134
|
+
|
135
|
+
subject.post(write_key, batch)
|
136
|
+
end
|
137
|
+
|
138
|
+
context 'with a stub' do
|
139
|
+
before do
|
140
|
+
allow(described_class).to receive(:stub) { true }
|
141
|
+
end
|
142
|
+
|
143
|
+
it 'returns a 200 response' do
|
144
|
+
expect(subject.post(write_key, batch).status).to eq(200)
|
145
|
+
end
|
146
|
+
|
147
|
+
it 'has a nil error' do
|
148
|
+
expect(subject.post(write_key, batch).error).to be_nil
|
149
|
+
end
|
150
|
+
|
151
|
+
it 'logs a debug statement' do
|
152
|
+
expect(subject.logger).to receive(:debug).with(/stubbed request to/)
|
153
|
+
subject.post(write_key, batch)
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
context 'a real request' do
|
158
|
+
RSpec.shared_examples('retried request') do |status_code, body|
|
159
|
+
let(:status_code) { status_code }
|
160
|
+
let(:body) { body }
|
161
|
+
let(:retries) { 4 }
|
162
|
+
let(:backoff_policy) { FakeBackoffPolicy.new([1000, 1000, 1000]) }
|
163
|
+
subject {
|
164
|
+
described_class.new(retries: retries,
|
165
|
+
backoff_policy: backoff_policy)
|
166
|
+
}
|
167
|
+
|
168
|
+
it 'retries the request' do
|
169
|
+
expect(subject)
|
170
|
+
.to receive(:sleep)
|
171
|
+
.exactly(retries - 1).times
|
172
|
+
.with(1)
|
173
|
+
.and_return(nil)
|
174
|
+
subject.post(write_key, batch)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
RSpec.shared_examples('non-retried request') do |status_code, body|
|
179
|
+
let(:status_code) { status_code }
|
180
|
+
let(:body) { body }
|
181
|
+
let(:retries) { 4 }
|
182
|
+
let(:backoff) { 1 }
|
183
|
+
subject { described_class.new(retries: retries, backoff: backoff) }
|
184
|
+
|
185
|
+
it 'does not retry the request' do
|
186
|
+
expect(subject)
|
187
|
+
.to receive(:sleep)
|
188
|
+
.never
|
189
|
+
subject.post(write_key, batch)
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
context 'request is successful' do
|
194
|
+
let(:status_code) { 201 }
|
195
|
+
it 'returns a response code' do
|
196
|
+
expect(subject.post(write_key, batch).status).to eq(status_code)
|
197
|
+
end
|
198
|
+
|
199
|
+
it 'returns a nil error' do
|
200
|
+
expect(subject.post(write_key, batch).error).to be_nil
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
context 'request results in errorful response' do
|
205
|
+
let(:error) { 'this is an error' }
|
206
|
+
let(:response_body) { { error: error }.to_json }
|
207
|
+
|
208
|
+
it 'returns the parsed error' do
|
209
|
+
expect(subject.post(write_key, batch).error).to eq(error)
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
context 'a request returns a failure status code' do
|
214
|
+
# Server errors must be retried
|
215
|
+
it_behaves_like('retried request', 500, '{}')
|
216
|
+
it_behaves_like('retried request', 503, '{}')
|
217
|
+
|
218
|
+
# All 4xx errors other than 429 (rate limited) must be retried
|
219
|
+
it_behaves_like('retried request', 429, '{}')
|
220
|
+
it_behaves_like('non-retried request', 404, '{}')
|
221
|
+
it_behaves_like('non-retried request', 400, '{}')
|
222
|
+
end
|
223
|
+
|
224
|
+
context 'request or parsing of response results in an exception' do
|
225
|
+
let(:response_body) { 'Malformed JSON ---' }
|
226
|
+
|
227
|
+
subject { described_class.new(retries: 0) }
|
228
|
+
|
229
|
+
it 'returns a -1 for status' do
|
230
|
+
expect(subject.post(write_key, batch).status).to eq(-1)
|
231
|
+
end
|
232
|
+
|
233
|
+
it 'has a connection error' do
|
234
|
+
error = subject.post(write_key, batch).error
|
235
|
+
expect(error).to match(/Connection error/)
|
236
|
+
end
|
237
|
+
|
238
|
+
it_behaves_like('retried request', 200, 'Malformed JSON ---')
|
239
|
+
end
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module Segment
|
4
|
+
class Analytics
|
5
|
+
describe Response do
|
6
|
+
describe '#status' do
|
7
|
+
it { expect(subject).to respond_to(:status) }
|
8
|
+
end
|
9
|
+
|
10
|
+
describe '#error' do
|
11
|
+
it { expect(subject).to respond_to(:error) }
|
12
|
+
end
|
13
|
+
|
14
|
+
describe '#initialize' do
|
15
|
+
let(:status) { 404 }
|
16
|
+
let(:error) { 'Oh No' }
|
17
|
+
|
18
|
+
subject { described_class.new(status, error) }
|
19
|
+
|
20
|
+
it 'sets the instance variable status' do
|
21
|
+
expect(subject.instance_variable_get(:@status)).to eq(status)
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'sets the instance variable error' do
|
25
|
+
expect(subject.instance_variable_get(:@error)).to eq(error)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module Segment
|
4
|
+
class Analytics
|
5
|
+
describe Worker do
|
6
|
+
describe '#init' do
|
7
|
+
it 'accepts string keys' do
|
8
|
+
queue = Queue.new
|
9
|
+
worker = Segment::Analytics::Worker.new(queue,
|
10
|
+
'secret',
|
11
|
+
'batch_size' => 100)
|
12
|
+
batch = worker.instance_variable_get(:@batch)
|
13
|
+
expect(batch.instance_variable_get(:@max_message_count)).to eq(100)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
describe '#run' do
|
18
|
+
before :all do
|
19
|
+
Segment::Analytics::Defaults::Request::BACKOFF = 0.1
|
20
|
+
end
|
21
|
+
|
22
|
+
after :all do
|
23
|
+
Segment::Analytics::Defaults::Request::BACKOFF = 30.0
|
24
|
+
end
|
25
|
+
|
26
|
+
it 'does not error if the endpoint is unreachable' do
|
27
|
+
expect do
|
28
|
+
Net::HTTP.any_instance.stub(:post).and_raise(Exception)
|
29
|
+
|
30
|
+
queue = Queue.new
|
31
|
+
queue << {}
|
32
|
+
worker = Segment::Analytics::Worker.new(queue, 'secret')
|
33
|
+
worker.run
|
34
|
+
|
35
|
+
expect(queue).to be_empty
|
36
|
+
|
37
|
+
Net::HTTP.any_instance.unstub(:post)
|
38
|
+
end.to_not raise_error
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'executes the error handler if the request is invalid' do
|
42
|
+
Segment::Analytics::Request
|
43
|
+
.any_instance
|
44
|
+
.stub(:post)
|
45
|
+
.and_return(Segment::Analytics::Response.new(400, 'Some error'))
|
46
|
+
|
47
|
+
status = error = nil
|
48
|
+
on_error = proc do |yielded_status, yielded_error|
|
49
|
+
sleep 0.2 # Make this take longer than thread spin-up (below)
|
50
|
+
status, error = yielded_status, yielded_error
|
51
|
+
end
|
52
|
+
|
53
|
+
queue = Queue.new
|
54
|
+
queue << {}
|
55
|
+
worker = described_class.new(queue, 'secret', :on_error => on_error)
|
56
|
+
|
57
|
+
# This is to ensure that Client#flush doesn't finish before calling
|
58
|
+
# the error handler.
|
59
|
+
Thread.new { worker.run }
|
60
|
+
sleep 0.1 # First give thread time to spin-up.
|
61
|
+
sleep 0.01 while worker.is_requesting?
|
62
|
+
|
63
|
+
Segment::Analytics::Request.any_instance.unstub(:post)
|
64
|
+
|
65
|
+
expect(queue).to be_empty
|
66
|
+
expect(status).to eq(400)
|
67
|
+
expect(error).to eq('Some error')
|
68
|
+
end
|
69
|
+
|
70
|
+
it 'does not call on_error if the request is good' do
|
71
|
+
on_error = proc do |status, error|
|
72
|
+
puts "#{status}, #{error}"
|
73
|
+
end
|
74
|
+
|
75
|
+
expect(on_error).to_not receive(:call)
|
76
|
+
|
77
|
+
queue = Queue.new
|
78
|
+
queue << Requested::TRACK
|
79
|
+
worker = described_class.new(queue,
|
80
|
+
'testsecret',
|
81
|
+
:on_error => on_error)
|
82
|
+
worker.run
|
83
|
+
|
84
|
+
expect(queue).to be_empty
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
describe '#is_requesting?' do
|
89
|
+
it 'does not return true if there isn\'t a current batch' do
|
90
|
+
queue = Queue.new
|
91
|
+
worker = Segment::Analytics::Worker.new(queue, 'testsecret')
|
92
|
+
|
93
|
+
expect(worker.is_requesting?).to eq(false)
|
94
|
+
end
|
95
|
+
|
96
|
+
it 'returns true if there is a current batch' do
|
97
|
+
queue = Queue.new
|
98
|
+
queue << Requested::TRACK
|
99
|
+
worker = Segment::Analytics::Worker.new(queue, 'testsecret')
|
100
|
+
|
101
|
+
worker_thread = Thread.new { worker.run }
|
102
|
+
eventually { expect(worker.is_requesting?).to eq(true) }
|
103
|
+
|
104
|
+
worker_thread.join
|
105
|
+
expect(worker.is_requesting?).to eq(false)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|