betterlog 0.1.0 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/.travis.yml +5 -0
- data/Dockerfile +46 -0
- data/Gemfile +5 -1
- data/LICENSE +10 -199
- data/Makefile +78 -0
- data/Rakefile +7 -3
- data/TODO.md +1 -0
- data/VERSION +1 -1
- data/betterlog/healthz.go +71 -0
- data/betterlog.gemspec +20 -10
- data/bin/betterlog +176 -175
- data/bin/betterlog_pusher +34 -0
- data/cmd/betterlog-server/LICENSE +13 -0
- data/cmd/betterlog-server/main.go +165 -0
- data/{log.yml → config/log.yml} +5 -35
- data/lib/betterlog/global_metadata.rb +9 -14
- data/lib/betterlog/log/event.rb +135 -133
- data/lib/betterlog/log/event_formatter.rb +99 -97
- data/lib/betterlog/log/severity.rb +38 -36
- data/lib/betterlog/log.rb +163 -146
- data/lib/betterlog/log_event_formatter.rb +41 -39
- data/lib/betterlog/logger.rb +88 -0
- data/lib/betterlog/notifiers.rb +28 -0
- data/lib/betterlog/railtie.rb +8 -0
- data/lib/betterlog/version.rb +1 -1
- data/lib/betterlog.rb +13 -7
- data/spec/betterlog/global_metadata_spec.rb +38 -0
- data/spec/betterlog/log_spec.rb +221 -0
- data/spec/betterlog/logger_spec.rb +65 -0
- data/spec/spec_helper.rb +13 -0
- metadata +82 -28
- data/betterdocs.gemspec +0 -53
- data/lib/betterdocs/version.rb +0 -8
- data/lib/betterlog/betterlog_railtie.rb +0 -5
data/lib/betterlog/log.rb
CHANGED
@@ -4,178 +4,195 @@ require 'betterlog/log/event'
|
|
4
4
|
require 'betterlog/log/event_formatter'
|
5
5
|
require 'betterlog/log/severity'
|
6
6
|
|
7
|
-
|
8
|
-
|
7
|
+
module Betterlog
|
8
|
+
class Log
|
9
|
+
include Tins::SexySingleton
|
9
10
|
|
10
|
-
|
11
|
-
|
12
|
-
end
|
11
|
+
class_attr_accessor :default_logger
|
12
|
+
self.default_logger = Logger.new(STDERR)
|
13
13
|
|
14
|
-
|
15
|
-
|
16
|
-
# @param object this object is logged
|
17
|
-
# @param **rest additional data is logged as well.
|
18
|
-
# @return [ Log ] this object itself.
|
19
|
-
def info(object, **rest)
|
20
|
-
protect do
|
21
|
-
emit Log::Event.ify(object, severity: __method__, rest: rest)
|
14
|
+
def logger
|
15
|
+
defined?(Rails) && Rails.logger || self.class.default_logger
|
22
16
|
end
|
23
|
-
end
|
24
17
|
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
18
|
+
# Logs a message on severity info.
|
19
|
+
#
|
20
|
+
# @param object this object is logged
|
21
|
+
# @param **rest additional data is logged as well.
|
22
|
+
# @return [ Log ] this object itself.
|
23
|
+
def info(object, **rest)
|
24
|
+
protect do
|
25
|
+
emit Log::Event.ify(object, severity: __method__, rest: rest)
|
26
|
+
end
|
33
27
|
end
|
34
|
-
end
|
35
28
|
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
29
|
+
# Logs a message on severity warn.
|
30
|
+
#
|
31
|
+
# @param object this object is logged
|
32
|
+
# @param **rest additional data is logged as well.
|
33
|
+
# @return [ Log ] this object itself.
|
34
|
+
def warn(object, **rest)
|
35
|
+
protect do
|
36
|
+
emit Log::Event.ify(object, severity: __method__, rest: rest)
|
37
|
+
end
|
44
38
|
end
|
45
|
-
end
|
46
39
|
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
40
|
+
# Logs a message on severity debug.
|
41
|
+
#
|
42
|
+
# @param object this object is logged
|
43
|
+
# @param **rest additional data is logged as well.
|
44
|
+
# @return [ Log ] this object itself.
|
45
|
+
def debug(object, **rest)
|
46
|
+
protect do
|
47
|
+
emit Log::Event.ify(object, severity: __method__, rest: rest)
|
48
|
+
end
|
55
49
|
end
|
56
|
-
end
|
57
50
|
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
51
|
+
# Logs a message on severity error.
|
52
|
+
#
|
53
|
+
# @param object this object is logged
|
54
|
+
# @param **rest additional data is logged as well.
|
55
|
+
# @return [ Log ] this object itself.
|
56
|
+
def error(object, **rest)
|
57
|
+
protect do
|
58
|
+
emit Log::Event.ify(object, severity: __method__, rest: rest)
|
59
|
+
end
|
66
60
|
end
|
67
|
-
end
|
68
61
|
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
62
|
+
# Logs a message on severity fatal.
|
63
|
+
#
|
64
|
+
# @param object this object is logged
|
65
|
+
# @param **rest additional data is logged as well.
|
66
|
+
# @return [ Log ] this object itself.
|
67
|
+
def fatal(object, **rest)
|
68
|
+
protect do
|
69
|
+
emit Log::Event.ify(object, severity: __method__, rest: rest)
|
70
|
+
end
|
78
71
|
end
|
79
|
-
end
|
80
72
|
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
event = build_metric(metric: metric, type: type, value: value, **rest)
|
92
|
-
emit event
|
73
|
+
# Logs a message on severity debug, by default, this can be changed by
|
74
|
+
# passing the severity: keyword.
|
75
|
+
#
|
76
|
+
# @param object this object is logged
|
77
|
+
# @param **rest additional data is logged as well.
|
78
|
+
# @return [ Log ] this object itself.
|
79
|
+
def output(object, **rest)
|
80
|
+
protect do
|
81
|
+
emit Log::Event.ify(object, severity: rest[:severity], rest: rest)
|
82
|
+
end
|
93
83
|
end
|
94
|
-
end
|
95
84
|
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
rescue => error
|
110
|
-
e = Log::Event.ify(error)
|
111
|
-
rest |= e.as_hash.subhash(:error_class, :backtrace, :message)
|
112
|
-
rest[:message] = "#{rest[:message]} while measuring metric #{metric}"
|
113
|
-
raise error
|
114
|
-
ensure
|
115
|
-
protect do
|
116
|
-
event = build_metric(metric: metric, type: 'seconds', value: timed_duration, **rest)
|
117
|
-
emit event
|
85
|
+
# Logs a metric on severity debug, by default, this can be changed by passing
|
86
|
+
# the severity: keyword.
|
87
|
+
#
|
88
|
+
# @param metric the name of the recorded metric.
|
89
|
+
# @param type of the recorded metric.
|
90
|
+
# @param value of the recorded metric.
|
91
|
+
# @param **rest additional rest is logged as well.
|
92
|
+
# @return [ Log ] this object itself.
|
93
|
+
def metric(metric:, type:, value:, **rest)
|
94
|
+
protect do
|
95
|
+
event = build_metric(metric: metric, type: type, value: value, **rest)
|
96
|
+
emit event
|
97
|
+
end
|
118
98
|
end
|
119
|
-
end
|
120
99
|
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
100
|
+
# Logs a time measure on severity debug, by default, this can be changed by
|
101
|
+
# passing the severity: keyword.
|
102
|
+
#
|
103
|
+
# If an error occurs during measurement details about it are added to the
|
104
|
+
# metric event.
|
105
|
+
#
|
106
|
+
# @param metric the name of the recorded metric.
|
107
|
+
# @param **rest additional rest is logged as well.
|
108
|
+
# @param block the block around which the measure is teaken.
|
109
|
+
# @return [ Log ] this object itself.
|
110
|
+
def measure(metric:, **rest, &block)
|
111
|
+
raise ArgumentError, 'must be called with a block' unless block_given?
|
112
|
+
time_block { yield }
|
113
|
+
rescue => error
|
114
|
+
e = Log::Event.ify(error)
|
115
|
+
rest |= e.as_hash.subhash(:error_class, :backtrace, :message)
|
116
|
+
rest[:message] = "#{rest[:message]} while measuring metric #{metric}"
|
117
|
+
raise error
|
118
|
+
ensure
|
119
|
+
protect do
|
120
|
+
event = build_metric(metric: metric, type: 'seconds', value: timed_duration, **rest)
|
121
|
+
emit event
|
122
|
+
end
|
135
123
|
end
|
136
|
-
self
|
137
|
-
end
|
138
124
|
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
type: type,
|
148
|
-
value: value,
|
149
|
-
} | rest,
|
150
|
-
severity: severity
|
151
|
-
)
|
152
|
-
end
|
125
|
+
def context(data_hash)
|
126
|
+
GlobalMetadata.add data_hash
|
127
|
+
self
|
128
|
+
end
|
129
|
+
|
130
|
+
def self.context(data_hash)
|
131
|
+
instance.context(data_hash)
|
132
|
+
end
|
153
133
|
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
134
|
+
private
|
135
|
+
|
136
|
+
def protect
|
137
|
+
yield
|
138
|
+
rescue => e
|
139
|
+
begin
|
140
|
+
# Try logging e once by ourselves
|
141
|
+
emit Log::Event.ify(e, severity: :fatal)
|
142
|
+
rescue
|
143
|
+
# Ok, I give up let's use logger directly instead
|
144
|
+
logger.fatal(
|
145
|
+
"Crashed during logging with #{e.class}: #{e.message}):\n"\
|
146
|
+
"#{e.backtrace * ?\n}"
|
147
|
+
)
|
148
|
+
end
|
149
|
+
self
|
150
|
+
end
|
151
|
+
|
152
|
+
def build_metric(metric:, type:, value:, **rest)
|
153
|
+
severity = rest.fetch(:severity, :debug)
|
154
|
+
rest |= {
|
155
|
+
message: "a metric #{metric} of type #{type}",
|
156
|
+
}
|
157
|
+
Log::Event.ify(
|
158
|
+
{
|
159
|
+
metric: metric,
|
160
|
+
type: type,
|
161
|
+
value: value,
|
162
|
+
} | rest,
|
163
|
+
severity: severity
|
164
|
+
)
|
160
165
|
end
|
161
|
-
|
162
|
-
|
166
|
+
|
167
|
+
def emit(event)
|
168
|
+
l = caller_locations.reverse_each.each_cons(3).find { |c, n1, n2|
|
169
|
+
n2.absolute_path =~ /betterlog\/log\.rb/ and break c # TODO check if this still works
|
170
|
+
}
|
171
|
+
if l
|
172
|
+
event[:location] = [ l.absolute_path, l.lineno ] * ?:
|
173
|
+
end
|
174
|
+
event[:emitter] = self.class.name.downcase
|
163
175
|
notify(event)
|
176
|
+
logger.send(event.severity.to_sym, event.to_json)
|
177
|
+
self
|
178
|
+
ensure
|
179
|
+
GlobalMetadata.data.clear
|
164
180
|
end
|
165
|
-
logger.send(event.severity.to_sym, event.to_json)
|
166
|
-
self
|
167
|
-
end
|
168
181
|
|
169
|
-
|
170
|
-
|
171
|
-
|
182
|
+
def notify(event)
|
183
|
+
if event.notify?
|
184
|
+
Notifiers.notify(event)
|
185
|
+
self
|
186
|
+
end
|
187
|
+
end
|
172
188
|
|
173
|
-
|
189
|
+
thread_local :timed_duration
|
174
190
|
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
191
|
+
def time_block
|
192
|
+
s = Time.now
|
193
|
+
yield
|
194
|
+
ensure
|
195
|
+
self.timed_duration = Time.now - s
|
196
|
+
end
|
180
197
|
end
|
181
198
|
end
|
@@ -1,47 +1,49 @@
|
|
1
|
-
|
2
|
-
|
1
|
+
module Betterlog
|
2
|
+
class LogEventFormatter < ActiveSupport::Logger::Formatter
|
3
|
+
include ActiveSupport::TaggedLogging::Formatter
|
3
4
|
|
4
|
-
|
5
|
-
|
6
|
-
|
5
|
+
def emitter
|
6
|
+
'legacy'
|
7
|
+
end
|
7
8
|
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
9
|
+
def call(severity, timestamp, program, message)
|
10
|
+
super
|
11
|
+
message = message.to_s
|
12
|
+
if cc.log.legacy_supported
|
13
|
+
if message.blank?
|
14
|
+
return ''
|
15
|
+
elsif !Log::Event.is?(message)
|
16
|
+
m = message.sub(/\s+$/, '')
|
17
|
+
timestamp = timestamp.utc.iso8601(3)
|
18
|
+
event = Log::Event.new(
|
19
|
+
emitter: emitter,
|
20
|
+
timestamp: timestamp,
|
21
|
+
message: m,
|
22
|
+
severity: severity.to_s.downcase,
|
23
|
+
# tags: current_tags,
|
24
|
+
)
|
25
|
+
if backtrace = m.grep(/^\s*([^:]+):(\d+)/)
|
26
|
+
if backtrace.size > 1
|
27
|
+
event[:backtrace] = backtrace.map(&:chomp)
|
28
|
+
event[:message] = 'a logged backtrace'
|
29
|
+
end
|
28
30
|
end
|
31
|
+
if l = caller_locations.reverse_each.each_cons(2).find { |c, n|
|
32
|
+
n.absolute_path =~ /\/lib\/ruby\/.*?\/logger\.rb/ and break c
|
33
|
+
}
|
34
|
+
then
|
35
|
+
event[:location] = [ l.absolute_path, l.lineno ] * ?:
|
36
|
+
end
|
37
|
+
program and event[:program] = program
|
38
|
+
message = event.to_json
|
29
39
|
end
|
30
|
-
if l = caller_locations.reverse_each.each_cons(2).find { |c, n|
|
31
|
-
n.absolute_path =~ /\/lib\/ruby\/.*?\/logger\.rb/ and break c
|
32
|
-
}
|
33
|
-
then
|
34
|
-
event[:location] = [ l.absolute_path, l.lineno ] * ?:
|
35
|
-
end
|
36
|
-
program and event[:program] = program
|
37
|
-
message = event.to_json
|
38
40
|
end
|
41
|
+
rescue => e
|
42
|
+
Betterlog::Log.logger.error(e)
|
43
|
+
ensure
|
44
|
+
# Do not "message << ?\n" - A frozn string may be passed in
|
45
|
+
message.end_with?(?\n) or message = "#{message}\n"
|
46
|
+
return message
|
39
47
|
end
|
40
|
-
rescue => e
|
41
|
-
Rails.logger.error(e)
|
42
|
-
ensure
|
43
|
-
# Do not "message << ?\n" - A frozn string may be passed in
|
44
|
-
message.end_with?(?\n) or message = "#{message}\n"
|
45
|
-
return message
|
46
48
|
end
|
47
49
|
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
require 'redis'
|
2
|
+
|
3
|
+
module Betterlog
|
4
|
+
class Logger < ::Logger
|
5
|
+
def initialize(redis, shift_age = 0, shift_size = 1048576, name: nil, **opts)
|
6
|
+
@redis = redis
|
7
|
+
@name = name || self.class.name
|
8
|
+
super(@logdev, shift_age, shift_size, **opts)
|
9
|
+
end
|
10
|
+
|
11
|
+
private def redis_write(msg)
|
12
|
+
# Redis string limit is at 512MB, stop before that after warning a lot.
|
13
|
+
if @redis.strlen(@name) > 511 * 1024 ** 2
|
14
|
+
return nil
|
15
|
+
end
|
16
|
+
if @redis.strlen(@name) > 510 * 1024 ** 2
|
17
|
+
@redis.append @name, "\nRedis memory limit will soon be reached =>"\
|
18
|
+
" Log output to redis stops now unless log data is pushed away!\n"
|
19
|
+
return nil
|
20
|
+
end
|
21
|
+
@redis.append @name, msg
|
22
|
+
self
|
23
|
+
end
|
24
|
+
|
25
|
+
|
26
|
+
def add(severity, message = nil, progname = nil)
|
27
|
+
severity ||= UNKNOWN
|
28
|
+
if severity < @level
|
29
|
+
return true
|
30
|
+
end
|
31
|
+
if progname.nil?
|
32
|
+
progname = @progname
|
33
|
+
end
|
34
|
+
if message.nil?
|
35
|
+
if block_given?
|
36
|
+
message = yield
|
37
|
+
else
|
38
|
+
message = progname
|
39
|
+
progname = @progname
|
40
|
+
end
|
41
|
+
end
|
42
|
+
redis_write(
|
43
|
+
format_message(format_severity(severity), Time.now, progname, message))
|
44
|
+
true
|
45
|
+
end
|
46
|
+
|
47
|
+
def <<(msg)
|
48
|
+
redis_write(msg)
|
49
|
+
end
|
50
|
+
|
51
|
+
def clear
|
52
|
+
@redis.del @name
|
53
|
+
self
|
54
|
+
end
|
55
|
+
|
56
|
+
def each_chunk(chunk_size: 100 * 1024, &block)
|
57
|
+
chunk_size > 0 or raise ArgumentError, 'chunk_size > 0 required'
|
58
|
+
@redis.exists(@name) or return Enumerator.new {}
|
59
|
+
Enumerator.new do |y|
|
60
|
+
name_tmp = "#{@name}_#{rand}"
|
61
|
+
@redis.rename @name, name_tmp
|
62
|
+
s = 0
|
63
|
+
e = @redis.strlen(name_tmp) - 1
|
64
|
+
until s > e
|
65
|
+
y.yield @redis.getrange(name_tmp, s, s + chunk_size - 1)
|
66
|
+
s += chunk_size
|
67
|
+
end
|
68
|
+
@redis.del name_tmp
|
69
|
+
end.each(&block)
|
70
|
+
end
|
71
|
+
|
72
|
+
def each(chunk_size: 100 * 1024, &block)
|
73
|
+
chunk_size > 0 or raise ArgumentError, 'chunk_size > 0 required'
|
74
|
+
Enumerator.new do |y|
|
75
|
+
buffer = ''
|
76
|
+
each_chunk(chunk_size: chunk_size) do |chunk|
|
77
|
+
buffer << chunk
|
78
|
+
buffer.gsub!(/\A(.*?#$/)/) do |line|
|
79
|
+
y.yield(line)
|
80
|
+
''
|
81
|
+
end
|
82
|
+
end
|
83
|
+
buffer.length > 0 and y.yield(buffer)
|
84
|
+
end.each(&block)
|
85
|
+
end
|
86
|
+
include Enumerable
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Betterlog
|
2
|
+
module Notifiers
|
3
|
+
|
4
|
+
class_attr_accessor :notifiers
|
5
|
+
|
6
|
+
self.notifiers = Set[]
|
7
|
+
|
8
|
+
def self.register(notifier)
|
9
|
+
notifier.respond_to?(:notify) or raise TypeError,
|
10
|
+
"notifier has to respond to notify(message, hash) interface"
|
11
|
+
notifiers << notifier
|
12
|
+
self
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.notify(event)
|
16
|
+
notifiers.each do |notifier|
|
17
|
+
notifier.notify(event.notify?, event.as_hash)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.context(data_hash)
|
22
|
+
notifiers.each do |notifier|
|
23
|
+
notifier.respond_to?(:context) or next
|
24
|
+
notifier.context(data_hash)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
data/lib/betterlog/version.rb
CHANGED
data/lib/betterlog.rb
CHANGED
@@ -2,13 +2,19 @@ require 'tins/xt'
|
|
2
2
|
require 'json'
|
3
3
|
require 'logger'
|
4
4
|
require 'time'
|
5
|
-
require 'tins'
|
6
|
-
require 'rails'
|
7
|
-
|
8
|
-
require 'active_support'
|
9
5
|
require 'term/ansicolor'
|
10
6
|
|
11
|
-
|
12
|
-
|
7
|
+
module Betterlog
|
8
|
+
end
|
9
|
+
|
13
10
|
require 'betterlog/log'
|
14
|
-
require 'betterlog/
|
11
|
+
require 'betterlog/notifiers'
|
12
|
+
require 'betterlog/global_metadata'
|
13
|
+
require 'betterlog/logger'
|
14
|
+
|
15
|
+
if defined? Rails
|
16
|
+
require 'betterlog/log_event_formatter'
|
17
|
+
require 'betterlog/railtie'
|
18
|
+
end
|
19
|
+
|
20
|
+
Log = Betterlog::Log
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Betterlog::GlobalMetadata do
|
4
|
+
let :notifier do
|
5
|
+
Class.new do
|
6
|
+
def notify(message, hash) end
|
7
|
+
|
8
|
+
def context(data_hash) end
|
9
|
+
end.new
|
10
|
+
end
|
11
|
+
|
12
|
+
around do |example|
|
13
|
+
Betterlog::Notifiers.register(notifier)
|
14
|
+
example.run
|
15
|
+
ensure
|
16
|
+
Betterlog::Notifiers.notifiers.clear
|
17
|
+
described_class.data.clear
|
18
|
+
end
|
19
|
+
|
20
|
+
it 'can haz empty data' do
|
21
|
+
expect(described_class.data).to eq({})
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'can haz some data' do
|
25
|
+
described_class.data |= { foo: 'bar' }
|
26
|
+
expect(described_class.data).to eq({ foo: 'bar' })
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'can "add" data' do
|
30
|
+
expect(notifier).to receive(:context).with(foo: 'bar')
|
31
|
+
expect(described_class.add(foo: 'bar')).to eq described_class.instance
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'can "add" data via Log.context' do
|
35
|
+
expect(notifier).to receive(:context).with(foo: 'bar')
|
36
|
+
Betterlog::Log.context(foo: 'bar')
|
37
|
+
end
|
38
|
+
end
|