fluent-plugin-detect-exceptions 0.0.1

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,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'