fluent-plugin-detect-exceptions-with-error 0.0.1a

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,302 @@
1
+ # Copyright 2016 Google Inc. All rights reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+ module Fluent
16
+ Struct.new('Rule', :from_state, :pattern, :to_state)
17
+
18
+ # Configuration of the state machine that detects exceptions.
19
+ module ExceptionDetectorConfig
20
+ # Rule for a state transition: if pattern matches go to the given state.
21
+ class RuleTarget
22
+ attr_accessor :pattern, :to_state
23
+
24
+ def initialize(p, s)
25
+ @pattern = p
26
+ @to_state = s
27
+ end
28
+
29
+ def ==(other)
30
+ other.class == self.class && other.state == state
31
+ end
32
+
33
+ alias eql? ==
34
+
35
+ def hash
36
+ state.hash
37
+ end
38
+
39
+ def state
40
+ [@pattern, @to_state]
41
+ end
42
+ end
43
+
44
+ def self.rule(from_state, pattern, to_state)
45
+ Struct::Rule.new(from_state, pattern, to_state)
46
+ end
47
+
48
+ def self.supported
49
+ RULES_BY_LANG.keys
50
+ end
51
+
52
+ JAVA_RULES = [
53
+ rule(:start_state, /(?:ERROR)[:\r\n]/, :java),
54
+ rule(:java, /(?:Exception|Error|Throwable|V8 errors stack trace)[:\r\n]/, :java),
55
+ rule(:java, /^[\t ]+(?:eval )?at /, :java),
56
+ rule(:java, /^[\t ]*(?:Caused by|Suppressed):/, :java),
57
+ rule(:java, /^[\t ]*... \d+\ more/, :java)
58
+ ].freeze
59
+
60
+ PYTHON_RULES = [
61
+ rule(:start_state, /Traceback \(most recent call last\)/, :python),
62
+ rule(:python, /^[\t ]+File /, :python_code),
63
+ rule(:python_code, /[^\t ]/, :python),
64
+ rule(:python, /^(?:[^\s.():]+\.)*[^\s.():]+:/, :start_state)
65
+ ].freeze
66
+
67
+ PHP_RULES = [
68
+ rule(:start_state, /
69
+ (?:PHP\ (?:Notice|Parse\ error|Fatal\ error|Warning):)|
70
+ (?:exception\ '[^']+'\ with\ message\ ')/x, :php_stack_begin),
71
+ rule(:php_stack_begin, /^Stack trace:/, :php_stack_frames),
72
+ rule(:php_stack_frames, /^#\d/, :php_stack_frames),
73
+ rule(:php_stack_frames, /^\s+thrown in /, :start_state)
74
+ ].freeze
75
+
76
+ GO_RULES = [
77
+ rule(:start_state, /panic: /, :go_before_goroutine),
78
+ rule(:go_before_goroutine, /^$/, :go_goroutine),
79
+ rule(:go_goroutine, /^goroutine \d+ \[[^\]]+\]:$/, :go_frame_1),
80
+ rule(:go_frame_1, /(?:[^\s.():]+\.)*[^\s.():]\(/, :go_frame_2),
81
+ rule(:go_frame_1, /^$/, :go_before_goroutine),
82
+ rule(:go_frame_2, /^\s/, :go_frame_1)
83
+ ].freeze
84
+
85
+ RUBY_RULES = [
86
+ rule(:start_state, /Error \(.*\):$/, :ruby),
87
+ rule(:ruby, /^[\t ]+.*?\.rb:\d+:in `/, :ruby)
88
+ ].freeze
89
+
90
+ ALL_RULES = (
91
+ JAVA_RULES + PYTHON_RULES + PHP_RULES + GO_RULES + RUBY_RULES).freeze
92
+
93
+ RULES_BY_LANG = {
94
+ java: JAVA_RULES,
95
+ javascript: JAVA_RULES,
96
+ js: JAVA_RULES,
97
+ csharp: JAVA_RULES,
98
+ py: PYTHON_RULES,
99
+ python: PYTHON_RULES,
100
+ php: PHP_RULES,
101
+ go: GO_RULES,
102
+ rb: RUBY_RULES,
103
+ ruby: RUBY_RULES,
104
+ all: ALL_RULES
105
+ }.freeze
106
+
107
+ DEFAULT_FIELDS = %w(message log).freeze
108
+ end
109
+
110
+ # State machine that consumes individual log lines and detects
111
+ # multi-line stack traces.
112
+ class ExceptionDetector
113
+ def initialize(*languages)
114
+ @state = :start_state
115
+ @rules = Hash.new { |h, k| h[k] = [] }
116
+
117
+ languages = [:all] if languages.empty?
118
+
119
+ languages.each do |lang|
120
+ rule_config =
121
+ ExceptionDetectorConfig::RULES_BY_LANG.fetch(lang.downcase) do |_k|
122
+ raise ArgumentError, "Unknown language: #{lang}"
123
+ end
124
+
125
+ rule_config.each do |r|
126
+ target = ExceptionDetectorConfig::RuleTarget.new(r[:pattern],
127
+ r[:to_state])
128
+ @rules[r[:from_state]] << target
129
+ end
130
+ end
131
+
132
+ @rules.each_value(&:uniq!)
133
+ end
134
+
135
+ # Updates the state machine and returns the trace detection status:
136
+ # - no_trace: 'line' does not belong to an exception trace,
137
+ # - start_trace: 'line' starts a detected exception trace,
138
+ # - inside: 'line' is part of a detected exception trace,
139
+ # - end: the detected exception trace ends after 'line'.
140
+ def update(line)
141
+ trace_seen_before = transition(line)
142
+ # If the state machine fell back to the start state because there is no
143
+ # defined transition for 'line', trigger another state transition because
144
+ # 'line' may contain the beginning of another exception.
145
+ transition(line) unless trace_seen_before
146
+ new_state = @state
147
+ trace_seen_after = new_state != :start_state
148
+
149
+ case [trace_seen_before, trace_seen_after]
150
+ when [true, true]
151
+ :inside_trace
152
+ when [true, false]
153
+ :end_trace
154
+ when [false, true]
155
+ :start_trace
156
+ else
157
+ :no_trace
158
+ end
159
+ end
160
+
161
+ def reset
162
+ @state = :start_state
163
+ end
164
+
165
+ private
166
+
167
+ # Executes a transition of the state machine for the given line.
168
+ # Returns false if the line does not match any transition rule and the
169
+ # state machine was reset to the initial state.
170
+ def transition(line)
171
+ @rules[@state].each do |r|
172
+ next unless line =~ r.pattern
173
+ @state = r.to_state
174
+ return true
175
+ end
176
+ @state = :start_state
177
+ false
178
+ end
179
+ end
180
+
181
+ # Buffers and groups log records if they contain exception stack traces.
182
+ class TraceAccumulator
183
+ attr_reader :buffer_start_time
184
+
185
+ # If message_field is nil, the instance is set up to accumulate
186
+ # records that are plain strings (i.e. the whole record is concatenated).
187
+ # Otherwise, the instance accepts records that are dictionaries (usually
188
+ # originating from structured JSON logs) and accumulates just the
189
+ # content of the given message field.
190
+ # message_field may contain the empty string. In this case, the
191
+ # TraceAccumulator 'learns' the field name from the first record by checking
192
+ # for some pre-defined common field names of text logs.
193
+ # The named parameters max_lines and max_bytes limit the maximum amount
194
+ # of data to be buffered. The default value 0 indicates 'no limit'.
195
+ def initialize(message_field, languages, max_lines: 0, max_bytes: 0,
196
+ &emit_callback)
197
+ @exception_detector = Fluent::ExceptionDetector.new(*languages)
198
+ @max_lines = max_lines
199
+ @max_bytes = max_bytes
200
+ @message_field = message_field
201
+ @messages = []
202
+ @buffer_start_time = Time.now
203
+ @buffer_size = 0
204
+ @first_record = nil
205
+ @first_timestamp = nil
206
+ @emit = emit_callback
207
+ end
208
+
209
+ def push(time_sec, record)
210
+ message = extract_message(record)
211
+ if message.nil?
212
+ @exception_detector.reset
213
+ detection_status = :no_trace
214
+ else
215
+ force_flush if @max_bytes > 0 &&
216
+ @buffer_size + message.length > @max_bytes
217
+ detection_status = @exception_detector.update(message)
218
+ end
219
+
220
+ update_buffer(detection_status, time_sec, record, message)
221
+
222
+ force_flush if @max_lines > 0 && @messages.length == @max_lines
223
+ end
224
+
225
+ def flush
226
+ case @messages.length
227
+ when 0
228
+ return
229
+ when 1
230
+ @emit.call(@first_timestamp, @first_record)
231
+ else
232
+ combined_message = @messages.join
233
+ if @message_field.nil?
234
+ output_record = combined_message
235
+ else
236
+ output_record = @first_record
237
+ output_record[@message_field] = combined_message
238
+ end
239
+ @emit.call(@first_timestamp, output_record)
240
+ end
241
+ @messages = []
242
+ @first_record = nil
243
+ @first_timestamp = nil
244
+ @buffer_size = 0
245
+ end
246
+
247
+ def force_flush
248
+ flush
249
+ @exception_detector.reset
250
+ end
251
+
252
+ private
253
+
254
+ def extract_message(record)
255
+ if !@message_field.nil? && @message_field.empty?
256
+ ExceptionDetectorConfig::DEFAULT_FIELDS.each do |f|
257
+ if record.key?(f)
258
+ @message_field = f
259
+ break
260
+ end
261
+ end
262
+ end
263
+ @message_field.nil? ? record : record[@message_field]
264
+ end
265
+
266
+ def update_buffer(detection_status, time_sec, record, message)
267
+ trigger_emit = detection_status == :no_trace ||
268
+ detection_status == :end_trace
269
+ if @messages.empty? && trigger_emit
270
+ @emit.call(time_sec, record)
271
+ return
272
+ end
273
+
274
+ case detection_status
275
+ when :inside_trace
276
+ add(time_sec, record, message)
277
+ when :end_trace
278
+ add(time_sec, record, message)
279
+ flush
280
+ when :no_trace
281
+ flush
282
+ add(time_sec, record, message)
283
+ flush
284
+ when :start_trace
285
+ flush
286
+ add(time_sec, record, message)
287
+ end
288
+ end
289
+
290
+ def add(time_sec, record, message)
291
+ if @messages.empty?
292
+ @first_record = record unless @message_field.nil?
293
+ @first_timestamp = time_sec
294
+ @buffer_start_time = Time.now
295
+ end
296
+ unless message.nil?
297
+ @messages << message
298
+ @buffer_size += message.length
299
+ end
300
+ end
301
+ end
302
+ end
@@ -0,0 +1,137 @@
1
+ #
2
+ # Copyright 2016 Google Inc. All rights reserved.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ require 'fluent/plugin/exception_detector'
17
+ require 'fluent/output'
18
+
19
+ module Fluent
20
+ # This output plugin consumes a log stream of JSON objects which contain
21
+ # single-line log messages. If a consecutive sequence of log messages form
22
+ # an exception stack trace, they forwarded as a single, combined JSON
23
+ # object. Otherwise, the input log data is forwarded as is.
24
+ class DetectExceptionsOutput < Output
25
+ desc 'The field which contains the raw message text in the input JSON data.'
26
+ config_param :message, :string, default: ''
27
+ desc 'The prefix to be removed from the input tag when outputting a record.'
28
+ config_param :remove_tag_prefix, :string, default: ''
29
+ desc 'The interval of flushing the buffer for multiline format.'
30
+ config_param :multiline_flush_interval, :time, default: nil
31
+ desc 'Programming languages for which to detect exceptions. Default: all.'
32
+ config_param :languages, :array, value_type: :string, default: []
33
+ desc 'Maximum number of lines to flush (0 means no limit). Default: 1000.'
34
+ config_param :max_lines, :integer, default: 1000
35
+ desc 'Maximum number of bytes to flush (0 means no limit). Default: 0.'
36
+ config_param :max_bytes, :integer, default: 0
37
+ desc 'Separate log streams by this field in the input JSON data.'
38
+ config_param :stream, :string, default: ''
39
+
40
+ Fluent::Plugin.register_output('detect_exceptions', self)
41
+
42
+ def configure(conf)
43
+ super
44
+
45
+ if multiline_flush_interval
46
+ @check_flush_interval = [multiline_flush_interval * 0.1, 1].max
47
+ end
48
+
49
+ @languages = languages.map(&:to_sym)
50
+
51
+ # Maps log stream tags to a corresponding TraceAccumulator.
52
+ @accumulators = {}
53
+ end
54
+
55
+ def start
56
+ super
57
+
58
+ if multiline_flush_interval
59
+ @flush_buffer_mutex = Mutex.new
60
+ @stop_check = false
61
+ @thread = Thread.new(&method(:check_flush_loop))
62
+ end
63
+ end
64
+
65
+ def before_shutdown
66
+ flush_buffers
67
+ super if defined?(super)
68
+ end
69
+
70
+ def shutdown
71
+ # Before shutdown is not available in older fluentd versions.
72
+ # Hence, we make sure that we flush the buffers here as well.
73
+ flush_buffers
74
+ @thread.join if @multiline_flush_interval
75
+ super
76
+ end
77
+
78
+ def emit(tag, es, chain)
79
+ es.each do |time_sec, record|
80
+ process_record(tag, time_sec, record)
81
+ end
82
+ chain.next
83
+ end
84
+
85
+ private
86
+
87
+ def process_record(tag, time_sec, record)
88
+ synchronize do
89
+ log_id = [tag]
90
+ log_id.push(record.fetch(@stream, '')) unless @stream.empty?
91
+ unless @accumulators.key?(log_id)
92
+ out_tag = tag.sub(/^#{Regexp.escape(@remove_tag_prefix)}\./, '')
93
+ @accumulators[log_id] =
94
+ Fluent::TraceAccumulator.new(@message, @languages,
95
+ max_lines: @max_lines,
96
+ max_bytes: @max_bytes) do |t, r|
97
+ router.emit(out_tag, t, r)
98
+ end
99
+ end
100
+
101
+ @accumulators[log_id].push(time_sec, record)
102
+ end
103
+ end
104
+
105
+ def flush_buffers
106
+ synchronize do
107
+ @stop_check = true
108
+ @accumulators.each_value(&:force_flush)
109
+ end
110
+ end
111
+
112
+ def check_flush_loop
113
+ @flush_buffer_mutex.synchronize do
114
+ loop do
115
+ @flush_buffer_mutex.sleep(@check_flush_interval)
116
+ now = Time.now
117
+ break if @stop_check
118
+ @accumulators.each_value do |acc|
119
+ acc.force_flush if now - acc.buffer_start_time >
120
+ @multiline_flush_interval
121
+ end
122
+ end
123
+ end
124
+ rescue
125
+ log.error 'error in check_flush_loop', error: $ERROR_INFO.to_s
126
+ log.error_backtrace
127
+ end
128
+
129
+ def synchronize(&block)
130
+ if @multiline_flush_interval
131
+ @flush_buffer_mutex.synchronize(&block)
132
+ else
133
+ yield
134
+ end
135
+ end
136
+ end
137
+ end
data/test/helper.rb ADDED
@@ -0,0 +1,46 @@
1
+ # Copyright 2016 Google Inc. All rights reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require 'rubygems'
16
+ require 'bundler'
17
+
18
+ begin
19
+ Bundler.setup(:default, :development)
20
+ rescue Bundler::BundlerError => e
21
+ $stderr.puts e.message
22
+ $stderr.puts 'Run `bundle install` to install missing gems'
23
+ exit e.status_code
24
+ end
25
+
26
+ require 'test/unit'
27
+
28
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
29
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
30
+ require 'fluent/test'
31
+
32
+ unless ENV.key?('VERBOSE')
33
+ nulllogger = Object.new
34
+ nulllogger.instance_eval do |_|
35
+ def respond_to_missing?
36
+ true
37
+ end
38
+
39
+ def method_missing(_method, *_args) # rubocop:disable Style/MethodMissing
40
+ end
41
+ end
42
+ # global $log variable is used by fluentd
43
+ $log = nulllogger # rubocop:disable Style/GlobalVars
44
+ end
45
+
46
+ require 'fluent/plugin/out_detect_exceptions'
@@ -0,0 +1,73 @@
1
+ # Copyright 2016 Google Inc. All rights reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require 'benchmark'
16
+
17
+ require 'fluent/plugin/exception_detector'
18
+
19
+ size_in_m = 25
20
+ line_length = 50
21
+
22
+ size = size_in_m << 20
23
+
24
+ JAVA_EXC = <<END.freeze
25
+ Jul 09, 2015 3:23:29 PM com.google.devtools.search.cloud.feeder.MakeLog: RuntimeException: Run from this message!
26
+ at com.my.app.Object.do$a1(MakeLog.java:50)
27
+ at java.lang.Thing.call(Thing.java:10)
28
+ at com.my.app.Object.help(MakeLog.java:40)
29
+ at sun.javax.API.method(API.java:100)
30
+ at com.jetty.Framework.main(MakeLog.java:30)
31
+ END
32
+
33
+ PYTHON_EXC = <<END.freeze
34
+ Traceback (most recent call last):
35
+ File "/base/data/home/runtimes/python27/python27_lib/versions/third_party/webapp2-2.5.2/webapp2.py", line 1535, in __call__
36
+ rv = self.handle_exception(request, response, e)
37
+ File "/base/data/home/apps/s~nearfieldspy/1.378705245900539993/nearfieldspy.py", line 17, in start
38
+ return get()
39
+ File "/base/data/home/apps/s~nearfieldspy/1.378705245900539993/nearfieldspy.py", line 5, in get
40
+ raise Exception('spam', 'eggs')
41
+ Exception: ('spam', 'eggs')
42
+ END
43
+
44
+ chars = [('a'..'z'), ('A'..'Z')].map(&:to_a).flatten
45
+
46
+ random_text = (1..(size / line_length)).collect do
47
+ (0...line_length).map { chars[rand(chars.length)] }.join
48
+ end
49
+
50
+ exceptions = {
51
+ java: (JAVA_EXC * (size / JAVA_EXC.length)).lines,
52
+ python: (PYTHON_EXC * (size / PYTHON_EXC.length)).lines
53
+ }
54
+
55
+ puts "Start benchmark. Input size #{size_in_m}M."
56
+ Benchmark.bm do |x|
57
+ languages = Fluent::ExceptionDetectorConfig::RULES_BY_LANG.keys
58
+ languages.each do |lang|
59
+ buffer = Fluent::TraceAccumulator.new(nil, lang) {}
60
+ x.report("#{lang}_detector_random_text") do
61
+ random_text.each { |l| buffer.push(0, l) }
62
+ end
63
+ end
64
+ [:java, :python, :all].each do |detector_lang|
65
+ buffer = Fluent::TraceAccumulator.new(nil, detector_lang) {}
66
+ exc_languages = detector_lang == :all ? exceptions.keys : [detector_lang]
67
+ exc_languages.each do |exc_lang|
68
+ x.report("#{detector_lang}_detector_#{exc_lang}_stacks") do
69
+ exceptions[exc_lang].each { |l| buffer.push(0, l) }
70
+ end
71
+ end
72
+ end
73
+ end