fluent-plugin-detect-exceptions 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,287 @@
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,
54
+ /(?:Exception|Error|Throwable|V8 errors stack trace)[:\r\n]/,
55
+ :java),
56
+ rule(:java, /^[\t ]+(?:eval )?at /, :java),
57
+ rule(:java, /^[\t ]*(?:Caused by|Suppressed):/, :java),
58
+ rule(:java, /^[\t ]*... \d+\ more/, :java)
59
+ ].freeze
60
+
61
+ PYTHON_RULES = [
62
+ rule(:start_state, /Traceback \(most recent call last\)/, :python),
63
+ rule(:python, /^[\t ]+File /, :python_code),
64
+ rule(:python_code, /[^\t ]/, :python),
65
+ rule(:python, /^(?:[^\s.():]+\.)*[^\s.():]+:/, :start_state)
66
+ ].freeze
67
+
68
+ PHP_RULES = [
69
+ rule(:start_state, /PHP (?:Notice|Parse error|Fatal error|Warning):/,
70
+ :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
+ def initialize(message_field, languages, &emit_callback)
194
+ @exception_detector = Fluent::ExceptionDetector.new(*languages)
195
+ @message_field = message_field
196
+ @messages = []
197
+ @buffer_start_time = Time.now
198
+ @first_record = nil
199
+ @first_timestamp = nil
200
+ @emit = emit_callback
201
+ end
202
+
203
+ def push(time_sec, record)
204
+ if !@message_field.nil? && @message_field.empty?
205
+ ExceptionDetectorConfig::DEFAULT_FIELDS.each do |f|
206
+ if record.key?(f)
207
+ @message_field = f
208
+ break
209
+ end
210
+ end
211
+ end
212
+ message = @message_field.nil? ? record : record[@message_field]
213
+ if message.nil?
214
+ @exception_detector.reset
215
+ detection_status = :no_trace
216
+ else
217
+ detection_status = @exception_detector.update(message)
218
+ end
219
+
220
+ update_buffer(detection_status, time_sec, record, message)
221
+ end
222
+
223
+ def flush
224
+ case @messages.length
225
+ when 0
226
+ return
227
+ when 1
228
+ @emit.call(@first_timestamp, @first_record)
229
+ else
230
+ combined_message = @messages.join
231
+ if @message_field.nil?
232
+ output_record = combined_message
233
+ else
234
+ output_record = @first_record
235
+ output_record[@message_field] = combined_message
236
+ end
237
+ @emit.call(@first_timestamp, output_record)
238
+ end
239
+ @messages = []
240
+ @first_record = nil
241
+ @first_timestamp = nil
242
+ end
243
+
244
+ def force_flush
245
+ flush
246
+ @exception_detector.reset
247
+ end
248
+
249
+ def length
250
+ @messages.length
251
+ end
252
+
253
+ private
254
+
255
+ def update_buffer(detection_status, time_sec, record, message)
256
+ trigger_emit = detection_status == :no_trace ||
257
+ detection_status == :end_trace
258
+ if @messages.empty? && trigger_emit
259
+ @emit.call(time_sec, record)
260
+ return
261
+ end
262
+ case detection_status
263
+ when :inside_trace
264
+ add(time_sec, record, message)
265
+ when :end_trace
266
+ add(time_sec, record, message)
267
+ flush
268
+ when :no_trace
269
+ flush
270
+ add(time_sec, record, message)
271
+ flush
272
+ when :start_trace
273
+ flush
274
+ add(time_sec, record, message)
275
+ end
276
+ end
277
+
278
+ def add(time_sec, record, message)
279
+ if @messages.empty?
280
+ @first_record = record unless @message_field.nil?
281
+ @first_timestamp = time_sec
282
+ @buffer_start_time = Time.now
283
+ end
284
+ @messages << message unless message.nil?
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,131 @@
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 in a detected stack trace. Default: 1000.'
34
+ config_param :max_lines, :integer, default: 1000
35
+
36
+ Fluent::Plugin.register_output('detect_exceptions', self)
37
+
38
+ def configure(conf)
39
+ super
40
+
41
+ if multiline_flush_interval
42
+ @check_flush_interval = [multiline_flush_interval * 0.1, 1].max
43
+ end
44
+
45
+ @languages = languages.map(&:to_sym)
46
+
47
+ # Maps log stream tags to a corresponding TraceAccumulator.
48
+ @accumulators = {}
49
+ end
50
+
51
+ def start
52
+ super
53
+
54
+ if multiline_flush_interval
55
+ @flush_buffer_mutex = Mutex.new
56
+ @stop_check = false
57
+ @thread = Thread.new(&method(:check_flush_loop))
58
+ end
59
+ end
60
+
61
+ def before_shutdown
62
+ flush_buffers
63
+ super if defined?(super)
64
+ end
65
+
66
+ def shutdown
67
+ # Before shutdown is not available in older fluentd versions.
68
+ # Hence, we make sure that we flush the buffers here as well.
69
+ flush_buffers
70
+ @thread.join if multiline_flush_interval
71
+ super
72
+ end
73
+
74
+ def emit(tag, es, chain)
75
+ es.each do |time_sec, record|
76
+ process_record(tag, time_sec, record)
77
+ end
78
+ chain.next
79
+ end
80
+
81
+ private
82
+
83
+ def process_record(tag, time_sec, record)
84
+ synchronize do
85
+ unless @accumulators.key?(tag)
86
+ out_tag = tag.sub(/^#{Regexp.escape(remove_tag_prefix)}\./, '')
87
+ @accumulators[tag] =
88
+ Fluent::TraceAccumulator.new(message, @languages) do |t, r|
89
+ router.emit(out_tag, t, r)
90
+ end
91
+ end
92
+
93
+ @accumulators[tag].push(time_sec, record)
94
+
95
+ @accumulators[tag].force_flush if @accumulators[tag].length > max_lines
96
+ end
97
+ end
98
+
99
+ def flush_buffers
100
+ synchronize do
101
+ @stop_check = true
102
+ @accumulators.each_value(&:force_flush)
103
+ end
104
+ end
105
+
106
+ def check_flush_loop
107
+ @flush_buffer_mutex.synchronize do
108
+ loop do
109
+ @flush_buffer_mutex.sleep(@check_flush_interval)
110
+ now = Time.now
111
+ break if @stop_check
112
+ @accumulators.each_value do |acc|
113
+ needs_flush = now - acc.buffer_start_time > multiline_flush_interval
114
+ acc.force_flush if needs_flush
115
+ end
116
+ end
117
+ end
118
+ rescue
119
+ log.error 'error in check_flush_loop', error: $ERROR_INFO.to_s
120
+ log.error_backtrace
121
+ end
122
+
123
+ def synchronize(&block)
124
+ if multiline_flush_interval
125
+ @flush_buffer_mutex.synchronize(&block)
126
+ else
127
+ yield
128
+ end
129
+ end
130
+ end
131
+ 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'