puma-plugin-telemetry_too 0.0.1.alpha1

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,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puma
4
+ class Plugin
5
+ module TelemetryToo
6
+ # Configuration object for plugin
7
+ class Config
8
+ DEFAULT_PUMA_TELEMETRY = [
9
+ # Total booted workers.
10
+ 'workers.booted',
11
+
12
+ # Total number of workers configured.
13
+ 'workers.total',
14
+
15
+ # Current number of threads spawned.
16
+ 'workers.spawned_threads',
17
+
18
+ # Maximum number of threads that can run .
19
+ 'workers.max_threads',
20
+
21
+ # Number of requests performed so far.
22
+ 'workers.requests_count',
23
+
24
+ # Number of requests waiting to be processed.
25
+ 'queue.backlog',
26
+
27
+ # Maximum number of requests held in Puma's Reactor which is used for
28
+ # asyncronously buffering request bodies. This stat is reset on every
29
+ # call, so it's the maximum value observed since the last call
30
+ 'queue.backlog_max',
31
+
32
+ # Maximum number of requests that have been fully buffered by the
33
+ # Reactor and placed in a ready queue, but have not yet been picked
34
+ # up by a server thread. This stat is reset on every call, so it's
35
+ # the maximum value observed since the last stat call.
36
+ 'queue.reactor_max',
37
+
38
+ # Free capacity that could be utilized, i.e. if backlog
39
+ # is growing, and we still have capacity available, it
40
+ # could mean that load balancing is not performing well.
41
+ 'queue.capacity'
42
+ ].freeze
43
+
44
+ TARGETS = {
45
+ dogstatsd: TelemetryToo::Targets::DatadogStatsdTarget,
46
+ io: TelemetryToo::Targets::IOTarget,
47
+ log: TelemetryToo::Targets::LogTarget
48
+ }.freeze
49
+
50
+ # Whenever telemetry should run with puma
51
+ # - default: false
52
+ attr_accessor :enabled
53
+
54
+ # Number of seconds to delay first telemetry
55
+ # - default: 5
56
+ attr_accessor :initial_delay
57
+
58
+ # Seconds between publishing telemetry
59
+ # - default: 5
60
+ attr_accessor :frequency
61
+
62
+ # List of targets which are meant to publish telemetry.
63
+ # Target should implement `#call` method accepting
64
+ # a single argument - so it can be even a simple proc.
65
+ # - default: []
66
+ attr_accessor :targets
67
+
68
+ # Which metrics to publish from puma stats. You can select
69
+ # a subset from default ones that interest you the most.
70
+ # - default: DEFAULT_PUMA_TELEMETRY
71
+ attr_accessor :puma_telemetry
72
+
73
+ # Whenever to publish socket telemetry.
74
+ # - default: false
75
+ attr_accessor :socket_telemetry
76
+
77
+ # Symbol representing method to parse the `Socket::Option`, or
78
+ # the whole implementation as a lambda. Available options:
79
+ # - `:inspect`, based on the `Socket::Option#inspect` method,
80
+ # it's the safest and slowest way to extract the info. `inspect`
81
+ # output might not be available, i.e. on AWS Fargate
82
+ # - `:unpack`, parse binary data given by `Socket::Option`. Fastest
83
+ # way (12x compared to `inspect`) but depends on kernel headers
84
+ # and fields ordering within the struct. It should almost always
85
+ # match though. DEFAULT
86
+ # - proc/lambda, `Socket::Option` will be given as an argument, it
87
+ # should return the value of `unacked` field as an integer.
88
+ #
89
+ attr_accessor :socket_parser
90
+
91
+ def initialize
92
+ @enabled = false
93
+ @initial_delay = 5
94
+ @frequency = 5
95
+ @targets = []
96
+ @puma_telemetry = DEFAULT_PUMA_TELEMETRY
97
+ @socket_telemetry = false
98
+ @socket_parser = :unpack
99
+ end
100
+
101
+ def enabled?
102
+ !!@enabled
103
+ end
104
+
105
+ def socket_telemetry!
106
+ # These structs are platform specific, and not available on macOS,
107
+ # for example. If they're undefined, then we cannot capture socket
108
+ # telemetry. We'll warn in that case.
109
+ if defined?(Socket::SOL_TCP) && defined?(Socket::TCP_INFO)
110
+ @socket_telemetry = true
111
+ else
112
+ warn("Cannot capture socket telemetry on this platform (#{RUBY_PLATFORM}); socket_telemetry is disabled.")
113
+ end
114
+ end
115
+
116
+ def socket_telemetry?
117
+ @socket_telemetry
118
+ end
119
+
120
+ def add_target(name_or_target, **args)
121
+ return @targets.push(name_or_target) unless name_or_target.is_a?(Symbol)
122
+
123
+ target = TARGETS.fetch(name_or_target) do
124
+ raise TelemetryToo::Error, "Unknown Target: #{name_or_target.inspect}, #{args.inspect}"
125
+ end
126
+
127
+ @targets.push(target.new(**args))
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puma
4
+ class Plugin
5
+ module TelemetryToo
6
+ # Helper for working with Puma stats
7
+ module CommonData
8
+ TELEMETRY_TO_METHODS = {
9
+ 'workers.booted' => :workers_booted,
10
+ 'workers.total' => :workers_total,
11
+ 'workers.spawned_threads' => :workers_spawned_threads,
12
+ 'workers.max_threads' => :workers_max_threads,
13
+ 'workers.requests_count' => :workers_requests_count,
14
+ 'queue.backlog' => :queue_backlog,
15
+ 'queue.backlog_max' => :queue_backlog_max,
16
+ 'queue.reactor_max' => :queue_reactor_max,
17
+ 'queue.capacity' => :queue_capacity
18
+ }.freeze
19
+
20
+ def initialize(stats)
21
+ @stats = stats
22
+ end
23
+
24
+ def workers_booted
25
+ @stats.fetch(:booted_workers, 1)
26
+ end
27
+
28
+ def workers_total
29
+ @stats.fetch(:workers, 1)
30
+ end
31
+
32
+ def metrics(selected)
33
+ selected.each_with_object({}) do |metric, obj|
34
+ next unless TELEMETRY_TO_METHODS.key?(metric)
35
+
36
+ obj[metric] = public_send(TELEMETRY_TO_METHODS[metric])
37
+ end
38
+ end
39
+ end
40
+
41
+ # Handles the case of non clustered mode, where `workers` isn't configured
42
+ class WorkerData
43
+ include CommonData
44
+
45
+ def workers_max_threads
46
+ @stats.fetch(:max_threads, 0)
47
+ end
48
+
49
+ def workers_requests_count
50
+ @stats.fetch(:requests_count, 0)
51
+ end
52
+
53
+ def workers_spawned_threads
54
+ @stats.fetch(:running, 0)
55
+ end
56
+
57
+ def queue_backlog
58
+ @stats.fetch(:backlog, 0)
59
+ end
60
+
61
+ def queue_capacity
62
+ @stats.fetch(:pool_capacity, 0)
63
+ end
64
+
65
+ def queue_reactor_max
66
+ @stats.fetch(:reactor_max, 0)
67
+ end
68
+
69
+ def queue_backlog_max
70
+ @stats.fetch(:backlog_max, 0)
71
+ end
72
+ end
73
+
74
+ # Handles the case of clustered mode, where we have statistics
75
+ # for all the workers. This class takes care of summing all
76
+ # relevant data.
77
+ class ClusteredData
78
+ include CommonData
79
+
80
+ def workers_max_threads
81
+ sum_stat(:max_threads)
82
+ end
83
+
84
+ def workers_requests_count
85
+ sum_stat(:requests_count)
86
+ end
87
+
88
+ def workers_spawned_threads
89
+ sum_stat(:running)
90
+ end
91
+
92
+ def queue_backlog
93
+ sum_stat(:backlog)
94
+ end
95
+
96
+ def queue_capacity
97
+ sum_stat(:pool_capacity)
98
+ end
99
+
100
+ def queue_reactor_max
101
+ sum_stat(:reactor_max)
102
+ end
103
+
104
+ def queue_backlog_max
105
+ sum_stat(:backlog_max)
106
+ end
107
+
108
+ private
109
+
110
+ def sum_stat(stat)
111
+ @stats[:worker_status].reduce(0) do |sum, data|
112
+ (data.dig(:last_status, stat) || 0) + sum
113
+ end
114
+ end
115
+ end
116
+
117
+ # Pulls TCP INFO data from socket
118
+ class SocketData
119
+ UNACKED_REGEXP = /\ unacked=(?<unacked>\d+)\ /
120
+
121
+ def initialize(ios, parser)
122
+ @sockets = ios.select { |io| io.respond_to?(:getsockopt) && io.is_a?(TCPSocket) }
123
+ @parser =
124
+ case parser
125
+ when :inspect then method(:parse_with_inspect)
126
+ when :unpack then method(:parse_with_unpack)
127
+ when Proc then parser
128
+ end
129
+ end
130
+
131
+ # Number of unacknowledged connections in the sockets, which
132
+ # we know as socket backlog.
133
+ #
134
+ def unacked
135
+ @sockets.sum do |socket|
136
+ @parser.call(socket.getsockopt(Socket::SOL_TCP,
137
+ Socket::TCP_INFO))
138
+ end
139
+ end
140
+
141
+ def metrics
142
+ {
143
+ 'sockets.backlog' => unacked
144
+ }
145
+ end
146
+
147
+ private
148
+
149
+ # The Socket::Option returned by `getsockopt` doesn't provide
150
+ # any kind of accessors for data inside. It decodes it on demand
151
+ # for `inspect` as strings in C implementation. It looks like
152
+ #
153
+ # #<Socket::Option: INET TCP INFO state=LISTEN
154
+ # ca_state=Open
155
+ # retransmits=0
156
+ # probes=0
157
+ # backoff=0
158
+ # options=0
159
+ # rto=0.000000s
160
+ # ato=0.000000s
161
+ # snd_mss=0
162
+ # rcv_mss=0
163
+ # unacked=0
164
+ # sacked=5
165
+ # lost=0
166
+ # retrans=0
167
+ # fackets=0
168
+ # last_data_sent=0.000s
169
+ # last_ack_sent=0.000s
170
+ # last_data_recv=0.000s
171
+ # last_ack_recv=0.000s
172
+ # pmtu=0
173
+ # rcv_ssthresh=0
174
+ # rtt=0.000000s
175
+ # rttvar=0.000000s
176
+ # snd_ssthresh=0
177
+ # snd_cwnd=10
178
+ # advmss=0
179
+ # reordering=3
180
+ # rcv_rtt=0.000000s
181
+ # rcv_space=0
182
+ # total_retrans=0
183
+ # (128 bytes too long)>
184
+ #
185
+ # That's why pulling the `unacked` field by parsing
186
+ # `inspect` output is one of the ways to retrieve it.
187
+ #
188
+ def parse_with_inspect(tcp_info)
189
+ tcp_match = tcp_info.inspect.match(UNACKED_REGEXP)
190
+
191
+ return 0 if tcp_match.nil?
192
+
193
+ tcp_match[:unacked].to_i
194
+ end
195
+
196
+ # The above inspect data might not be available everywhere (looking at you
197
+ # AWS Fargate Host running on kernel 4.14!), but we might still recover it
198
+ # by manually unpacking the binary data based on linux headers. For example
199
+ # below is tcp info struct from `linux/tcp.h` header file, from problematic
200
+ # host rocking kernel 4.14.
201
+ #
202
+ # struct tcp_info {
203
+ # __u8 tcpi_state;
204
+ # __u8 tcpi_ca_state;
205
+ # __u8 tcpi_retransmits;
206
+ # __u8 tcpi_probes;
207
+ # __u8 tcpi_backoff;
208
+ # __u8 tcpi_options;
209
+ # __u8 tcpi_snd_wscale : 4, tcpi_rcv_wscale : 4;
210
+ # __u8 tcpi_delivery_rate_app_limited:1;
211
+ #
212
+ # __u32 tcpi_rto;
213
+ # __u32 tcpi_ato;
214
+ # __u32 tcpi_snd_mss;
215
+ # __u32 tcpi_rcv_mss;
216
+ #
217
+ # __u32 tcpi_unacked;
218
+ # __u32 tcpi_sacked;
219
+ # __u32 tcpi_lost;
220
+ # __u32 tcpi_retrans;
221
+ # __u32 tcpi_fackets;
222
+ #
223
+ # /* Times. */
224
+ # __u32 tcpi_last_data_sent;
225
+ # __u32 tcpi_last_ack_sent; /* Not remembered, sorry. */
226
+ # __u32 tcpi_last_data_recv;
227
+ # __u32 tcpi_last_ack_recv;
228
+ #
229
+ # /* Metrics. */
230
+ # __u32 tcpi_pmtu;
231
+ # __u32 tcpi_rcv_ssthresh;
232
+ # __u32 tcpi_rtt;
233
+ # __u32 tcpi_rttvar;
234
+ # __u32 tcpi_snd_ssthresh;
235
+ # __u32 tcpi_snd_cwnd;
236
+ # __u32 tcpi_advmss;
237
+ # __u32 tcpi_reordering;
238
+ #
239
+ # __u32 tcpi_rcv_rtt;
240
+ # __u32 tcpi_rcv_space;
241
+ #
242
+ # __u32 tcpi_total_retrans;
243
+ #
244
+ # __u64 tcpi_pacing_rate;
245
+ # __u64 tcpi_max_pacing_rate;
246
+ # __u64 tcpi_bytes_acked; /* RFC4898 tcpEStatsAppHCThruOctetsAcked */
247
+ # __u64 tcpi_bytes_received; /* RFC4898 tcpEStatsAppHCThruOctetsReceived */
248
+ # __u32 tcpi_segs_out; /* RFC4898 tcpEStatsPerfSegsOut */
249
+ # __u32 tcpi_segs_in; /* RFC4898 tcpEStatsPerfSegsIn */
250
+ #
251
+ # __u32 tcpi_notsent_bytes;
252
+ # __u32 tcpi_min_rtt;
253
+ # __u32 tcpi_data_segs_in; /* RFC4898 tcpEStatsDataSegsIn */
254
+ # __u32 tcpi_data_segs_out; /* RFC4898 tcpEStatsDataSegsOut */
255
+ #
256
+ # __u64 tcpi_delivery_rate;
257
+ #
258
+ # __u64 tcpi_busy_time; /* Time (usec) busy sending data */
259
+ # __u64 tcpi_rwnd_limited; /* Time (usec) limited by receive window */
260
+ # __u64 tcpi_sndbuf_limited; /* Time (usec) limited by send buffer */
261
+ # };
262
+ #
263
+ # Now nowing types and order of fields we can easily parse binary data
264
+ # by using
265
+ # - `C` flag for `__u8` type - 8-bit unsigned (unsigned char)
266
+ # - `L` flag for `__u32` type - 32-bit unsigned, native endian (uint32_t)
267
+ # - `Q` flag for `__u64` type - 64-bit unsigned, native endian (uint64_t)
268
+ #
269
+ # Complete `unpack` would look like `C8 L24 Q4 L6 Q4`, but we are only
270
+ # interested in `unacked` field at the moment, that's why we only parse
271
+ # till this field by unpacking with `C8 L5`.
272
+ #
273
+ # If you find that it's not giving correct results, then please fall back
274
+ # to inspect, or update this code to accept unpack sequence. But in the
275
+ # end unpack is preferable, as it's 12x faster than inspect.
276
+ #
277
+ # Tested against:
278
+ # - Amazon Linux 2 with kernel 4.14 & 5.10
279
+ # - Ubuntu 20.04 with kernel 5.13
280
+ #
281
+ def parse_with_unpack(tcp_info)
282
+ tcp_info.unpack('C8L5').last
283
+ end
284
+ end
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Puma
6
+ class Plugin
7
+ module TelemetryToo
8
+ module Formatters
9
+ # JSON formatter, expects `call` method accepting telemetry hash
10
+ class JSONFormatter
11
+ def self.call(telemetry)
12
+ ::JSON.dump(telemetry)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puma
4
+ class Plugin
5
+ module TelemetryToo
6
+ module Formatters
7
+ # Logfmt formatter, expects `call` method accepting telemetry hash
8
+ class LogfmtFormatter
9
+ def self.call(telemetry)
10
+ telemetry.map { |k, v| "#{String(k)}=#{v.inspect}" }.join(' ')
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puma
4
+ class Plugin
5
+ module TelemetryToo
6
+ module Formatters
7
+ # A pass-through formatter - it returns the telemetry Hash it was given
8
+ class PassthroughFormatter
9
+ def self.call(telemetry)
10
+ telemetry
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../formatters/json_formatter'
4
+ require_relative '../formatters/logfmt_formatter'
5
+ require_relative '../formatters/passthrough_formatter'
6
+ require_relative '../transforms/cloud_watch_transform'
7
+ require_relative '../transforms/l2met_transform'
8
+ require_relative '../transforms/passthrough_transform'
9
+
10
+ module Puma
11
+ class Plugin
12
+ module TelemetryToo
13
+ module Targets
14
+ # A base class for other Targets concerned with formatting telemetry
15
+ class BaseFormattingTarget
16
+ def initialize(formatter: :json, transform: :cloud_watch)
17
+ @formatter = FORMATTERS.fetch(formatter) { formatter }
18
+ @transform = TRANSFORMS.fetch(transform) { transform }
19
+ end
20
+
21
+ def call(_telemetry)
22
+ raise "#{__method__} must be implemented by #{self.class.name}"
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :formatter, :transform
28
+
29
+ FORMATTERS = {
30
+ json: Formatters::JSONFormatter,
31
+ logfmt: Formatters::LogfmtFormatter,
32
+ passthrough: Formatters::PassthroughFormatter
33
+ }.freeze
34
+ private_constant :FORMATTERS
35
+
36
+ TRANSFORMS = {
37
+ cloud_watch: Transforms::CloudWatchTranform,
38
+ l2met: Transforms::L2metTransform,
39
+ passthrough: Transforms::PassthroughTransform
40
+ }.freeze
41
+ private_constant :TRANSFORMS
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puma
4
+ class Plugin
5
+ module TelemetryToo
6
+ module Targets
7
+ # Target wrapping Datadog Statsd client. You can configure
8
+ # all details like _metrics prefix_ and _tags_ in the client
9
+ # itself.
10
+ #
11
+ # ## Example
12
+ #
13
+ # require "datadog/statsd"
14
+ #
15
+ # client = Datadog::Statsd.new(namespace: "ruby.puma",
16
+ # tags: {
17
+ # service: "my-webapp",
18
+ # env: ENV["RAILS_ENV"],
19
+ # version: ENV["CODE_VERSION"]
20
+ # })
21
+ #
22
+ # DatadogStatsdTarget.new(client: client)
23
+ #
24
+ class DatadogStatsdTarget
25
+ def initialize(client:)
26
+ @client = client
27
+ end
28
+
29
+ # We are using `gauge` metric type, which means that only the last
30
+ # value will get send to datadog. DD Statsd client is using extra
31
+ # thread since v5 for aggregating metrics before it sends them.
32
+ #
33
+ # This means that we could publish metrics from here several times
34
+ # before they get flushed from the aggregation thread, and when they
35
+ # do, only the last values will get sent.
36
+ #
37
+ # That's why we are explicitly calling flush here, in order to persist
38
+ # all metrics, and not only the most recent ones.
39
+ #
40
+ def call(telemetry)
41
+ telemetry.each do |metric, value|
42
+ @client.gauge(metric, value)
43
+ end
44
+
45
+ @client.flush(sync: true)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_formatting_target'
4
+
5
+ module Puma
6
+ class Plugin
7
+ module TelemetryToo
8
+ module Targets
9
+ # Simple IO Target, publishing metrics to STDOUT or logs
10
+ class IOTarget < BaseFormattingTarget
11
+ def initialize(io: $stdout, formatter: :json, transform: :cloud_watch)
12
+ super(formatter: formatter, transform: transform)
13
+ @io = io
14
+ end
15
+
16
+ def call(telemetry)
17
+ io.puts(formatter.call(transform.call(telemetry)))
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :io
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require_relative 'base_formatting_target'
5
+
6
+ module Puma
7
+ class Plugin
8
+ module TelemetryToo
9
+ module Targets
10
+ # Simple Log Target, publishing metrics to a Ruby ::Logger at stdout
11
+ # at the INFO log level
12
+ class LogTarget < BaseFormattingTarget
13
+ def initialize(logger: ::Logger.new($stdout), formatter: :logfmt, transform: :passthrough)
14
+ super(formatter: formatter, transform: transform)
15
+ @logger = logger
16
+ end
17
+
18
+ def call(telemetry)
19
+ logger.info(formatter.call(transform.call(telemetry)))
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :logger
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Puma
6
+ class Plugin
7
+ module TelemetryToo
8
+ module Transforms
9
+ # Replace dots with dashes for better support of AWS CloudWatch Log
10
+ # Metric filters, as they don't support dots in key names.
11
+ # Expects `call` method accepting telemetry Hash
12
+ class CloudWatchTranform
13
+ def self.call(telemetry)
14
+ telemetry.transform_keys { |k| String(k).tr('.', '-') }.tap do |data|
15
+ data['name'] = 'Puma::Plugin::TelemetryToo'
16
+ data['message'] = 'Publish telemetry'
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'pathname'
5
+
6
+ module Puma
7
+ class Plugin
8
+ module TelemetryToo
9
+ module Transforms
10
+ # L2Met (Logs to Metrics) transform that makes all keys a `sample#` in the L2Met format.
11
+ class L2metTransform
12
+ def self.call(telemetry)
13
+ new.call(telemetry)
14
+ end
15
+
16
+ def initialize(host_env: ENV, program_name: $PROGRAM_NAME, socket: Socket)
17
+ @host_env = host_env
18
+ @program_name = program_name
19
+ @socket = socket
20
+ end
21
+
22
+ def call(telemetry)
23
+ telemetry.transform_keys { |k| "sample##{k}" }.tap do |data|
24
+ data['name'] ||= 'Puma::Plugin::TelemetryToo'
25
+ data['source'] ||= source
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :host_env, :program_name, :socket
32
+
33
+ def source
34
+ @source ||= host_env['L2MET_SOURCE'] ||
35
+ host_env['DYNO'] || # For Heroku
36
+ host_with_exe_name # Last-ditch effort
37
+ end
38
+
39
+ def host_with_exe_name
40
+ "#{socket.gethostname}/#{Pathname(program_name).basename}"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end