fluent-plugin-multi-exceptions 0.0.13

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,48 @@
1
+ #!/usr/bin/env rake
2
+
3
+ require 'bundler'
4
+ Bundler::GemHelper.install_tasks
5
+
6
+ require 'rake/testtask'
7
+ require 'rubocop/rake_task'
8
+
9
+ desc 'Run Rubocop to check for style violations'
10
+ RuboCop::RakeTask.new
11
+
12
+ desc 'Run benchmark tests'
13
+ Rake::TestTask.new(:bench) do |test|
14
+ test.libs << 'lib' << 'test'
15
+ test.test_files = FileList['test/plugin/bench*.rb']
16
+ test.verbose = true
17
+ end
18
+
19
+ desc 'Run unit tests'
20
+ Rake::TestTask.new(:test) do |test|
21
+ test.libs << 'lib' << 'test'
22
+ test.test_files = FileList['test/plugin/test*.rb']
23
+ test.verbose = true
24
+ end
25
+
26
+ # Building the gem will use the local file mode, so ensure it's world-readable.
27
+ # https://github.com/GoogleCloudPlatform/fluent-plugin-detect-exceptions/issues/32
28
+ desc 'Fix file permissions'
29
+ task :fix_perms do
30
+ files = [
31
+ 'lib/fluent/plugin/*.rb'
32
+ ].flat_map do |file|
33
+ file.include?('*') ? Dir.glob(file) : [file]
34
+ end
35
+
36
+ files.each do |file|
37
+ mode = File.stat(file).mode & 0o777
38
+ next unless mode & 0o444 != 0o444
39
+ puts "Changing mode of #{file} from #{mode.to_s(8)} to "\
40
+ "#{(mode | 0o444).to_s(8)}"
41
+ chmod mode | 0o444, file
42
+ end
43
+ end
44
+
45
+ desc 'Run unit tests and RuboCop to check for style violations'
46
+ task all: [:test, :rubocop, :fix_perms]
47
+
48
+ task default: :all
@@ -0,0 +1,29 @@
1
+ Gem::Specification.new do |gem|
2
+ gem.name = 'fluent-plugin-multi-exceptions'
3
+ gem.description = <<-eos
4
+ Fluentd output plugin which detects exception stack traces in a stream of
5
+ JSON log messages and combines all single-line messages that belong to the
6
+ same stack trace into one multi-line message.
7
+ This is Not an official Google Ruby gem. Added Multiworker to true
8
+ eos
9
+ gem.summary = \
10
+ 'fluentd output plugin for combining stack traces as multi-line JSON logs'
11
+ gem.homepage = \
12
+ 'https://github.com/GoogleCloudPlatform/fluent-plugin-detect-exceptions'
13
+ gem.license = 'Apache-2.0'
14
+ gem.version = '0.0.13'
15
+ gem.authors = ['Deloitte Agents']
16
+ gem.email = ['gatolgaj@gmail.com']
17
+ gem.required_ruby_version = Gem::Requirement.new('>= 2.0')
18
+
19
+ gem.files = Dir['**/*'].keep_if { |file| File.file?(file) }
20
+ gem.test_files = gem.files.grep(/^(test)/)
21
+ gem.require_paths = ['lib']
22
+
23
+ gem.add_runtime_dependency 'fluentd', '>= 0.10'
24
+
25
+ gem.add_development_dependency 'rake', '~> 10.3'
26
+ gem.add_development_dependency 'rubocop', '= 0.42.0'
27
+ gem.add_development_dependency 'test-unit', '~> 3.0'
28
+ gem.add_development_dependency 'flexmock', '~> 2.0'
29
+ end
@@ -0,0 +1,368 @@
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_states, :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_or_states, pattern, to_state)
45
+ from_state_or_states = [from_state_or_states] unless
46
+ from_state_or_states.is_a?(Array)
47
+ Struct::Rule.new(from_state_or_states, pattern, to_state)
48
+ end
49
+
50
+ def self.supported
51
+ RULES_BY_LANG.keys
52
+ end
53
+
54
+ JAVA_RULES = [
55
+ rule([:start_state, :java_start_exception],
56
+ /(?:Exception|Error|Throwable|V8 errors stack trace)[:\r\n]/,
57
+ :java_after_exception),
58
+ rule(:java_after_exception, /^[\t ]*nested exception is:[\t ]*/,
59
+ :java_start_exception),
60
+ rule(:java_after_exception, /^[\r\n]*$/, :java_after_exception),
61
+ rule([:java_after_exception, :java], /^[\t ]+(?:eval )?at /, :java),
62
+
63
+ rule([:java_after_exception, :java],
64
+ # C# nested exception.
65
+ /^[\t ]+--- End of inner exception stack trace ---$/,
66
+ :java),
67
+
68
+ rule([:java_after_exception, :java],
69
+ # C# exception from async code.
70
+ /^--- End of stack trace from previous (?x:
71
+ )location where exception was thrown ---$/,
72
+ :java),
73
+
74
+ rule([:java_after_exception, :java], /^[\t ]*(?:Caused by|Suppressed):/,
75
+ :java_after_exception),
76
+ rule([:java_after_exception, :java],
77
+ /^[\t ]*... \d+ (?:more|common frames omitted)/, :java)
78
+ ].freeze
79
+
80
+ PYTHON_RULES = [
81
+ rule(:start_state, /^Traceback \(most recent call last\):$/, :python),
82
+ rule(:python, /^[\t ]+File /, :python_code),
83
+ rule(:python_code, /[^\t ]/, :python),
84
+ rule(:python, /^(?:[^\s.():]+\.)*[^\s.():]+:/, :start_state)
85
+ ].freeze
86
+
87
+ PHP_RULES = [
88
+ rule(:start_state, /
89
+ (?:PHP\ (?:Notice|Parse\ error|Fatal\ error|Warning):)|
90
+ (?:exception\ '[^']+'\ with\ message\ ')/x, :php_stack_begin),
91
+ rule(:php_stack_begin, /^Stack trace:/, :php_stack_frames),
92
+ rule(:php_stack_frames, /^#\d/, :php_stack_frames),
93
+ rule(:php_stack_frames, /^\s+thrown in /, :start_state)
94
+ ].freeze
95
+
96
+ GO_RULES = [
97
+ rule(:start_state, /\bpanic: /, :go_after_panic),
98
+ rule(:start_state, /http: panic serving/, :go_goroutine),
99
+ rule(:go_after_panic, /^$/, :go_goroutine),
100
+ rule([:go_after_panic, :go_after_signal, :go_frame_1],
101
+ /^$/, :go_goroutine),
102
+ rule(:go_after_panic, /^\[signal /, :go_after_signal),
103
+ rule(:go_goroutine, /^goroutine \d+ \[[^\]]+\]:$/, :go_frame_1),
104
+ rule(:go_frame_1, /^(?:[^\s.:]+\.)*[^\s.():]+\(|^created by /,
105
+ :go_frame_2),
106
+ rule(:go_frame_2, /^\s/, :go_frame_1)
107
+ ].freeze
108
+
109
+ RUBY_RULES = [
110
+ rule(:start_state, /Error \(.*\):$/, :ruby_before_rails_trace),
111
+ rule(:ruby_before_rails_trace, /^ $/, :ruby),
112
+ rule(:ruby_before_rails_trace, /^[\t ]+.*?\.rb:\d+:in `/, :ruby),
113
+ rule(:ruby, /^[\t ]+.*?\.rb:\d+:in `/, :ruby)
114
+ ].freeze
115
+
116
+ DART_RULES = [
117
+ rule(:start_state, /^Unhandled exception:$/, :dart_exc),
118
+ rule(:dart_exc, /^Instance of/, :dart_stack),
119
+ rule(:dart_exc, /^Exception/, :dart_stack),
120
+ rule(:dart_exc, /^Bad state/, :dart_stack),
121
+ rule(:dart_exc, /^IntegerDivisionByZeroException/, :dart_stack),
122
+ rule(:dart_exc, /^Invalid argument/, :dart_stack),
123
+ rule(:dart_exc, /^RangeError/, :dart_stack),
124
+ rule(:dart_exc, /^Assertion failed/, :dart_stack),
125
+ rule(:dart_exc, /^Cannot instantiate/, :dart_stack),
126
+ rule(:dart_exc, /^Reading static variable/, :dart_stack),
127
+ rule(:dart_exc, /^UnimplementedError/, :dart_stack),
128
+ rule(:dart_exc, /^Unsupported operation/, :dart_stack),
129
+ rule(:dart_exc, /^Concurrent modification/, :dart_stack),
130
+ rule(:dart_exc, /^Out of Memory/, :dart_stack),
131
+ rule(:dart_exc, /^Stack Overflow/, :dart_stack),
132
+ rule(:dart_exc, /^'.+?':.+?$/, :dart_type_err_1),
133
+ rule(:dart_type_err_1, /^#\d+\s+.+?\(.+?\)$/, :dart_stack),
134
+ rule(:dart_type_err_1, /^.+?$/, :dart_type_err_2),
135
+ rule(:dart_type_err_2, /^.*?\^.*?$/, :dart_type_err_3),
136
+ rule(:dart_type_err_3, /^$/, :dart_type_err_4),
137
+ rule(:dart_type_err_4, /^$/, :dart_stack),
138
+ rule(:dart_exc, /^FormatException/, :dart_format_err_1),
139
+ rule(:dart_format_err_1, /^#\d+\s+.+?\(.+?\)$/, :dart_stack),
140
+ rule(:dart_format_err_1, /^./, :dart_format_err_2),
141
+ rule(:dart_format_err_2, /^.*?\^/, :dart_format_err_3),
142
+ rule(:dart_format_err_3, /^$/, :dart_stack),
143
+ rule(:dart_exc, /^NoSuchMethodError:/, :dart_method_err_1),
144
+ rule(:dart_method_err_1, /^Receiver:/, :dart_method_err_2),
145
+ rule(:dart_method_err_2, /^Tried calling:/, :dart_method_err_3),
146
+ rule(:dart_method_err_3, /^Found:/, :dart_stack),
147
+ rule(:dart_method_err_3, /^#\d+\s+.+?\(.+?\)$/, :dart_stack),
148
+ rule(:dart_stack, /^#\d+\s+.+?\(.+?\)$/, :dart_stack),
149
+ rule(:dart_stack, /^<asynchronous suspension>$/, :dart_stack)
150
+ ].freeze
151
+
152
+ ALL_RULES = (
153
+ JAVA_RULES + PYTHON_RULES + PHP_RULES + GO_RULES + RUBY_RULES + DART_RULES
154
+ ).freeze
155
+
156
+ RULES_BY_LANG = {
157
+ java: JAVA_RULES,
158
+ javascript: JAVA_RULES,
159
+ js: JAVA_RULES,
160
+ csharp: JAVA_RULES,
161
+ py: PYTHON_RULES,
162
+ python: PYTHON_RULES,
163
+ php: PHP_RULES,
164
+ go: GO_RULES,
165
+ rb: RUBY_RULES,
166
+ ruby: RUBY_RULES,
167
+ dart: DART_RULES,
168
+ all: ALL_RULES
169
+ }.freeze
170
+
171
+ DEFAULT_FIELDS = %w(message log).freeze
172
+ end
173
+
174
+ # State machine that consumes individual log lines and detects
175
+ # multi-line stack traces.
176
+ class ExceptionDetector
177
+ def initialize(*languages)
178
+ @state = :start_state
179
+ @rules = Hash.new { |h, k| h[k] = [] }
180
+
181
+ languages = [:all] if languages.empty?
182
+
183
+ languages.each do |lang|
184
+ rule_config =
185
+ ExceptionDetectorConfig::RULES_BY_LANG.fetch(lang.downcase) do |_k|
186
+ raise ArgumentError, "Unknown language: #{lang}"
187
+ end
188
+
189
+ rule_config.each do |r|
190
+ target = ExceptionDetectorConfig::RuleTarget.new(r[:pattern],
191
+ r[:to_state])
192
+ r[:from_states].each do |from_state|
193
+ @rules[from_state] << target
194
+ end
195
+ end
196
+ end
197
+
198
+ @rules.each_value(&:uniq!)
199
+ end
200
+
201
+ # Updates the state machine and returns the trace detection status:
202
+ # - no_trace: 'line' does not belong to an exception trace,
203
+ # - start_trace: 'line' starts a detected exception trace,
204
+ # - inside: 'line' is part of a detected exception trace,
205
+ # - end: the detected exception trace ends after 'line'.
206
+ def update(line)
207
+ trace_seen_before = transition(line)
208
+ # If the state machine fell back to the start state because there is no
209
+ # defined transition for 'line', trigger another state transition because
210
+ # 'line' may contain the beginning of another exception.
211
+ transition(line) unless trace_seen_before
212
+ new_state = @state
213
+ trace_seen_after = new_state != :start_state
214
+
215
+ case [trace_seen_before, trace_seen_after]
216
+ when [true, true]
217
+ :inside_trace
218
+ when [true, false]
219
+ :end_trace
220
+ when [false, true]
221
+ :start_trace
222
+ else
223
+ :no_trace
224
+ end
225
+ end
226
+
227
+ def reset
228
+ @state = :start_state
229
+ end
230
+
231
+ private
232
+
233
+ # Executes a transition of the state machine for the given line.
234
+ # Returns false if the line does not match any transition rule and the
235
+ # state machine was reset to the initial state.
236
+ def transition(line)
237
+ @rules[@state].each do |r|
238
+ next unless line =~ r.pattern
239
+ @state = r.to_state
240
+ return true
241
+ end
242
+ @state = :start_state
243
+ false
244
+ end
245
+ end
246
+
247
+ # Buffers and groups log records if they contain exception stack traces.
248
+ class TraceAccumulator
249
+ attr_reader :buffer_start_time
250
+
251
+ # If message_field is nil, the instance is set up to accumulate
252
+ # records that are plain strings (i.e. the whole record is concatenated).
253
+ # Otherwise, the instance accepts records that are dictionaries (usually
254
+ # originating from structured JSON logs) and accumulates just the
255
+ # content of the given message field.
256
+ # message_field may contain the empty string. In this case, the
257
+ # TraceAccumulator 'learns' the field name from the first record by checking
258
+ # for some pre-defined common field names of text logs.
259
+ # The named parameters max_lines and max_bytes limit the maximum amount
260
+ # of data to be buffered. The default value 0 indicates 'no limit'.
261
+ def initialize(message_field, languages, max_lines: 0, max_bytes: 0,
262
+ &emit_callback)
263
+ @exception_detector = Fluent::ExceptionDetector.new(*languages)
264
+ @max_lines = max_lines
265
+ @max_bytes = max_bytes
266
+ @message_field = message_field
267
+ @messages = []
268
+ @buffer_start_time = Time.now
269
+ @buffer_size = 0
270
+ @first_record = nil
271
+ @first_timestamp = nil
272
+ @emit = emit_callback
273
+ end
274
+
275
+ def push(time_sec, record)
276
+ message = extract_message(record)
277
+ if message.nil?
278
+ @exception_detector.reset
279
+ detection_status = :no_trace
280
+ else
281
+ force_flush if @max_bytes > 0 &&
282
+ @buffer_size + message.length > @max_bytes
283
+ detection_status = @exception_detector.update(message)
284
+ end
285
+
286
+ update_buffer(detection_status, time_sec, record, message)
287
+
288
+ force_flush if @max_lines > 0 && @messages.length == @max_lines
289
+ end
290
+
291
+ def flush
292
+ case @messages.length
293
+ when 0
294
+ return
295
+ when 1
296
+ @emit.call(@first_timestamp, @first_record)
297
+ else
298
+ combined_message = @messages.join
299
+ if @message_field.nil?
300
+ output_record = combined_message
301
+ else
302
+ output_record = @first_record
303
+ output_record[@message_field] = combined_message
304
+ end
305
+ @emit.call(@first_timestamp, output_record)
306
+ end
307
+ @messages = []
308
+ @first_record = nil
309
+ @first_timestamp = nil
310
+ @buffer_size = 0
311
+ end
312
+
313
+ def force_flush
314
+ flush
315
+ @exception_detector.reset
316
+ end
317
+
318
+ private
319
+
320
+ def extract_message(record)
321
+ if !@message_field.nil? && @message_field.empty?
322
+ ExceptionDetectorConfig::DEFAULT_FIELDS.each do |f|
323
+ if record.key?(f)
324
+ @message_field = f
325
+ break
326
+ end
327
+ end
328
+ end
329
+ @message_field.nil? ? record : record[@message_field]
330
+ end
331
+
332
+ def update_buffer(detection_status, time_sec, record, message)
333
+ trigger_emit = detection_status == :no_trace ||
334
+ detection_status == :end_trace
335
+ if @messages.empty? && trigger_emit
336
+ @emit.call(time_sec, record)
337
+ return
338
+ end
339
+
340
+ case detection_status
341
+ when :inside_trace
342
+ add(time_sec, record, message)
343
+ when :end_trace
344
+ add(time_sec, record, message)
345
+ flush
346
+ when :no_trace
347
+ flush
348
+ add(time_sec, record, message)
349
+ flush
350
+ when :start_trace
351
+ flush
352
+ add(time_sec, record, message)
353
+ end
354
+ end
355
+
356
+ def add(time_sec, record, message)
357
+ if @messages.empty?
358
+ @first_record = record unless @message_field.nil?
359
+ @first_timestamp = time_sec
360
+ @buffer_start_time = Time.now
361
+ end
362
+ unless message.nil?
363
+ @messages << message
364
+ @buffer_size += message.length
365
+ end
366
+ end
367
+ end
368
+ end
@@ -0,0 +1,141 @@
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 prefix to be removed from the input tag when outputting a record.'
26
+ config_param :remove_tag_prefix, :string
27
+ desc 'The field which contains the raw message text in the input JSON data.'
28
+ config_param :message, :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 multi_workers_ready?
56
+ true
57
+ end
58
+
59
+ def start
60
+ super
61
+
62
+ if multiline_flush_interval
63
+ @flush_buffer_mutex = Mutex.new
64
+ @stop_check = false
65
+ @thread = Thread.new(&method(:check_flush_loop))
66
+ end
67
+ end
68
+
69
+ def before_shutdown
70
+ flush_buffers
71
+ super if defined?(super)
72
+ end
73
+
74
+ def shutdown
75
+ # Before shutdown is not available in older fluentd versions.
76
+ # Hence, we make sure that we flush the buffers here as well.
77
+ flush_buffers
78
+ @thread.join if @multiline_flush_interval
79
+ super
80
+ end
81
+
82
+ def emit(tag, es, chain)
83
+ es.each do |time_sec, record|
84
+ process_record(tag, time_sec, record)
85
+ end
86
+ chain.next
87
+ end
88
+
89
+ private
90
+
91
+ def process_record(tag, time_sec, record)
92
+ synchronize do
93
+ log_id = [tag]
94
+ log_id.push(record.fetch(@stream, '')) unless @stream.empty?
95
+ unless @accumulators.key?(log_id)
96
+ out_tag = tag.sub(/^#{Regexp.escape(@remove_tag_prefix)}\./, '')
97
+ @accumulators[log_id] =
98
+ Fluent::TraceAccumulator.new(@message, @languages,
99
+ max_lines: @max_lines,
100
+ max_bytes: @max_bytes) do |t, r|
101
+ router.emit(out_tag, t, r)
102
+ end
103
+ end
104
+
105
+ @accumulators[log_id].push(time_sec, record)
106
+ end
107
+ end
108
+
109
+ def flush_buffers
110
+ synchronize do
111
+ @stop_check = true
112
+ @accumulators.each_value(&:force_flush)
113
+ end
114
+ end
115
+
116
+ def check_flush_loop
117
+ @flush_buffer_mutex.synchronize do
118
+ loop do
119
+ @flush_buffer_mutex.sleep(@check_flush_interval)
120
+ now = Time.now
121
+ break if @stop_check
122
+ @accumulators.each_value do |acc|
123
+ acc.force_flush if now - acc.buffer_start_time >
124
+ @multiline_flush_interval
125
+ end
126
+ end
127
+ end
128
+ rescue
129
+ log.error 'error in check_flush_loop', error: $ERROR_INFO.to_s
130
+ log.error_backtrace
131
+ end
132
+
133
+ def synchronize(&block)
134
+ if @multiline_flush_interval
135
+ @flush_buffer_mutex.synchronize(&block)
136
+ else
137
+ yield
138
+ end
139
+ end
140
+ end
141
+ end