fluent-plugin-splunk-hec 1.1.2 → 1.2.4
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.
- checksums.yaml +4 -4
- data/Gemfile +2 -0
- data/Gemfile.lock +101 -29
- data/LICENSE +73 -5
- data/README.md +130 -77
- data/Rakefile +6 -1
- data/VERSION +1 -1
- data/fluent-plugin-splunk-hec.gemspec +10 -5
- data/lib/fluent/plugin/out_splunk.rb +313 -0
- data/lib/fluent/plugin/{out_splunk_hec → out_splunk}/match_formatter.rb +5 -3
- data/lib/fluent/plugin/out_splunk/version.rb +3 -0
- data/lib/fluent/plugin/out_splunk_hec.rb +144 -194
- data/lib/fluent/plugin/out_splunk_hec/version.rb +2 -0
- data/lib/fluent/plugin/out_splunk_ingest_api.rb +112 -0
- data/test/fluent/plugin/out_splunk_hec_test.rb +227 -225
- data/test/fluent/plugin/out_splunk_ingest_api_test.rb +244 -0
- data/test/test_helper.rb +10 -7
- metadata +69 -24
- data/test/lib/webmock/http_lib_adapters/httpclient_adapter.rb +0 -0
data/Rakefile
CHANGED
@@ -1,5 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'bundler/gem_tasks'
|
2
4
|
require 'rake/testtask'
|
5
|
+
require 'rubocop/rake_task'
|
6
|
+
|
7
|
+
RuboCop::RakeTask.new
|
3
8
|
|
4
9
|
Rake::TestTask.new(:test) do |t|
|
5
10
|
t.libs << 'test'
|
@@ -9,4 +14,4 @@ Rake::TestTask.new(:test) do |t|
|
|
9
14
|
t.warning = false
|
10
15
|
end
|
11
16
|
|
12
|
-
task :
|
17
|
+
task default: :test
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
1.
|
1
|
+
1.2.4
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
Gem::Specification.new do |spec|
|
2
4
|
spec.name = 'fluent-plugin-splunk-hec'
|
3
5
|
spec.version = File.read('VERSION')
|
@@ -31,15 +33,18 @@ Gem::Specification.new do |spec|
|
|
31
33
|
|
32
34
|
spec.required_ruby_version = '>= 2.3.0'
|
33
35
|
|
34
|
-
spec.add_runtime_dependency 'fluentd', '
|
36
|
+
spec.add_runtime_dependency 'fluentd', '>= 1.4'
|
35
37
|
spec.add_runtime_dependency 'multi_json', '~> 1.13'
|
36
|
-
spec.add_runtime_dependency 'net-http-persistent', '~> 3.
|
38
|
+
spec.add_runtime_dependency 'net-http-persistent', '~> 3.1'
|
39
|
+
spec.add_runtime_dependency 'openid_connect', '~> 1.1.8'
|
40
|
+
spec.add_runtime_dependency 'prometheus-client', '< 0.10.0'
|
37
41
|
|
38
42
|
spec.add_development_dependency 'bundler', '~> 2.0'
|
39
|
-
spec.add_development_dependency 'rake', '
|
43
|
+
spec.add_development_dependency 'rake', '>= 12.0'
|
40
44
|
# required by fluent/test.rb
|
41
|
-
spec.add_development_dependency 'test-unit', '~> 3.0'
|
42
45
|
spec.add_development_dependency 'minitest', '~> 5.0'
|
43
|
-
spec.add_development_dependency '
|
46
|
+
spec.add_development_dependency 'rubocop', '~> 0.63.1'
|
44
47
|
spec.add_development_dependency 'simplecov', '~> 0.16.1'
|
48
|
+
spec.add_development_dependency 'test-unit', '~> 3.0'
|
49
|
+
spec.add_development_dependency 'webmock', '~> 3.5.0'
|
45
50
|
end
|
@@ -0,0 +1,313 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'fluent/output'
|
4
|
+
require 'fluent/plugin/output'
|
5
|
+
require 'fluent/plugin/formatter'
|
6
|
+
require 'prometheus/client'
|
7
|
+
require 'benchmark'
|
8
|
+
|
9
|
+
module Fluent::Plugin
|
10
|
+
class SplunkOutput < Fluent::BufferedOutput
|
11
|
+
helpers :formatter
|
12
|
+
|
13
|
+
autoload :VERSION, 'fluent/plugin/out_splunk/version'
|
14
|
+
autoload :MatchFormatter, 'fluent/plugin/out_splunk/match_formatter'
|
15
|
+
|
16
|
+
KEY_FIELDS = %w[index host source sourcetype metric_name metric_value].freeze
|
17
|
+
TAG_PLACEHOLDER = '${tag}'
|
18
|
+
|
19
|
+
desc 'The host field for events, by default it uses the hostname of the machine that runnning fluentd. This is exclusive with `host_key`.'
|
20
|
+
config_param :host, :string, default: nil
|
21
|
+
|
22
|
+
desc 'Field name to contain host. This is exclusive with `host`.'
|
23
|
+
config_param :host_key, :string, default: nil
|
24
|
+
|
25
|
+
desc 'The source field for events, when not set, will be decided by HEC. This is exclusive with `source_key`.'
|
26
|
+
config_param :source, :string, default: nil
|
27
|
+
|
28
|
+
desc 'Field name to contain source. This is exclusive with `source`.'
|
29
|
+
config_param :source_key, :string, default: nil
|
30
|
+
|
31
|
+
desc 'The sourcetype field for events, when not set, will be decided by HEC. This is exclusive with `sourcetype_key`.'
|
32
|
+
config_param :sourcetype, :string, default: nil
|
33
|
+
|
34
|
+
desc 'Field name to contain sourcetype. This is exclusive with `sourcetype`.'
|
35
|
+
config_param :sourcetype_key, :string, default: nil
|
36
|
+
|
37
|
+
desc 'Field name to contain Splunk event time. By default will use fluentd\'d time'
|
38
|
+
config_param :time_key, :string, default: nil
|
39
|
+
|
40
|
+
desc 'The Splunk index to index events. When not set, will be decided by HEC. This is exclusive with `index_key`'
|
41
|
+
config_param :index, :string, default: nil
|
42
|
+
|
43
|
+
desc 'Field name to contain Splunk index name. This is exclusive with `index`.'
|
44
|
+
config_param :index_key, :string, default: nil
|
45
|
+
|
46
|
+
desc 'When set to true, all fields defined in `index_key`, `host_key`, `source_key`, `sourcetype_key`, `metric_name_key`, `metric_value_key` will not be removed from the original event.'
|
47
|
+
config_param :keep_keys, :bool, default: false
|
48
|
+
|
49
|
+
desc 'Define index-time fields for event data type, or metric dimensions for metric data type. Null value fields will be removed.'
|
50
|
+
config_section :fields, init: false, multi: false, required: false do
|
51
|
+
# this is blank on purpose
|
52
|
+
end
|
53
|
+
|
54
|
+
config_section :format do
|
55
|
+
config_set_default :usage, '**'
|
56
|
+
config_set_default :@type, 'json'
|
57
|
+
config_set_default :add_newline, false
|
58
|
+
end
|
59
|
+
|
60
|
+
desc <<~DESC
|
61
|
+
Whether to allow non-UTF-8 characters in user logs. If set to true, any
|
62
|
+
non-UTF-8 character would be replaced by the string specified by
|
63
|
+
`non_utf8_replacement_string`. If set to false, any non-UTF-8 character
|
64
|
+
would trigger the plugin to error out.
|
65
|
+
DESC
|
66
|
+
config_param :coerce_to_utf8, :bool, default: true
|
67
|
+
|
68
|
+
desc <<~DESC
|
69
|
+
If `coerce_to_utf8` is set to true, any non-UTF-8 character would be
|
70
|
+
replaced by the string specified here.
|
71
|
+
DESC
|
72
|
+
config_param :non_utf8_replacement_string, :string, default: ' '
|
73
|
+
|
74
|
+
def initialize
|
75
|
+
super
|
76
|
+
@registry = ::Prometheus::Client.registry
|
77
|
+
end
|
78
|
+
|
79
|
+
def configure(conf)
|
80
|
+
super
|
81
|
+
check_conflict
|
82
|
+
@api = construct_api
|
83
|
+
prepare_key_fields
|
84
|
+
configure_fields(conf)
|
85
|
+
configure_metrics(conf)
|
86
|
+
|
87
|
+
# @formatter_configs is from formatter helper
|
88
|
+
@formatters = @formatter_configs.map do |section|
|
89
|
+
MatchFormatter.new section.usage, formatter_create(usage: section.usage)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def write(chunk)
|
94
|
+
log.trace { "#{self.class}: Received new chunk, size=#{chunk.read.bytesize}" }
|
95
|
+
|
96
|
+
t = Benchmark.realtime do
|
97
|
+
write_to_splunk(chunk)
|
98
|
+
end
|
99
|
+
|
100
|
+
@metrics[:record_counter].increment(metric_labels, chunk.size_of_events)
|
101
|
+
@metrics[:bytes_counter].increment(metric_labels, chunk.bytesize)
|
102
|
+
@metrics[:write_records_histogram].observe(metric_labels, chunk.size_of_events)
|
103
|
+
@metrics[:write_bytes_histogram].observe(metric_labels, chunk.bytesize)
|
104
|
+
@metrics[:write_latency_histogram].observe(metric_labels, t)
|
105
|
+
end
|
106
|
+
|
107
|
+
def write_to_splunk(_chunk)
|
108
|
+
raise NotImplementedError("Child class should implement 'write_to_splunk'")
|
109
|
+
end
|
110
|
+
|
111
|
+
def construct_api
|
112
|
+
raise NotImplementedError("Child class should implement 'construct_api'")
|
113
|
+
end
|
114
|
+
|
115
|
+
protected
|
116
|
+
|
117
|
+
def prepare_event_payload(tag, time, record)
|
118
|
+
{
|
119
|
+
host: @host ? @host.call(tag, record) : @default_host,
|
120
|
+
# From the API reference
|
121
|
+
# http://docs.splunk.com/Documentation/Splunk/latest/RESTREF/RESTinput#services.2Fcollector
|
122
|
+
# `time` should be a string or unsigned integer.
|
123
|
+
# That's why we use `to_s` here.
|
124
|
+
time: time.to_f.to_s
|
125
|
+
}.tap do |payload|
|
126
|
+
payload[:index] = @index.call(tag, record) if @index
|
127
|
+
payload[:source] = @source.call(tag, record) if @source
|
128
|
+
payload[:sourcetype] = @sourcetype.call(tag, record) if @sourcetype
|
129
|
+
|
130
|
+
# delete nil fields otherwise will get format error from HEC
|
131
|
+
%i[host index source sourcetype].each { |f| payload.delete f if payload[f].nil? }
|
132
|
+
|
133
|
+
if @extra_fields
|
134
|
+
payload[:fields] = @extra_fields.map { |name, field| [name, record[field]] }.to_h
|
135
|
+
payload[:fields].compact!
|
136
|
+
# if a field is already in indexed fields, then remove it from the original event
|
137
|
+
@extra_fields.values.each { |field| record.delete field }
|
138
|
+
end
|
139
|
+
if formatter = @formatters.find { |f| f.match? tag }
|
140
|
+
record = formatter.format(tag, time, record)
|
141
|
+
end
|
142
|
+
payload[:event] = convert_to_utf8 record
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
def format_event(tag, time, record)
|
147
|
+
MultiJson.dump(prepare_event_payload(tag, time, record))
|
148
|
+
end
|
149
|
+
|
150
|
+
def process_response(response, _request_body)
|
151
|
+
log.trace { "[Response] POST #{@api}: #{response.inspect}" }
|
152
|
+
|
153
|
+
@metrics[:status_counter].increment(metric_labels(status: response.code.to_s))
|
154
|
+
|
155
|
+
# raise Exception to utilize Fluentd output plugin retry mechanism
|
156
|
+
raise "Server error (#{response.code}) for POST #{@api}, response: #{response.body}" if response.code.to_s.start_with?('5')
|
157
|
+
|
158
|
+
# For both success response (2xx) and client errors (4xx), we will consume the chunk.
|
159
|
+
# Because there probably a bug in the code if we get 4xx errors, retry won't do any good.
|
160
|
+
unless response.code.to_s.start_with?('2')
|
161
|
+
log.error "#{self.class}: Failed POST to #{@api}, response: #{response.body}"
|
162
|
+
log.error { "#{self.class}: Failed request body: #{post.body}" }
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
private
|
167
|
+
|
168
|
+
def check_conflict
|
169
|
+
KEY_FIELDS.each do |f|
|
170
|
+
kf = "#{f}_key"
|
171
|
+
raise Fluent::ConfigError, "Can not set #{f} and #{kf} at the same time." \
|
172
|
+
if %W[@#{f} @#{kf}].all? &method(:instance_variable_get)
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
def prepare_key_fields
|
177
|
+
KEY_FIELDS.each do |f|
|
178
|
+
v = instance_variable_get "@#{f}_key"
|
179
|
+
if v
|
180
|
+
attrs = v.split('.').freeze
|
181
|
+
if @keep_keys
|
182
|
+
instance_variable_set "@#{f}", ->(_, record) { attrs.inject(record) { |o, k| o[k] } }
|
183
|
+
else
|
184
|
+
instance_variable_set "@#{f}", lambda { |_, record|
|
185
|
+
attrs[0...-1].inject(record) { |o, k| o[k] }.delete(attrs[-1])
|
186
|
+
}
|
187
|
+
end
|
188
|
+
else
|
189
|
+
v = instance_variable_get "@#{f}"
|
190
|
+
next unless v
|
191
|
+
|
192
|
+
if v == TAG_PLACEHOLDER
|
193
|
+
instance_variable_set "@#{f}", ->(tag, _) { tag }
|
194
|
+
else
|
195
|
+
instance_variable_set "@#{f}", ->(_, _) { v }
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
# <fields> directive, which defines:
|
202
|
+
# * when data_type is event, index-time fields
|
203
|
+
# * when data_type is metric, metric dimensions
|
204
|
+
def configure_fields(conf)
|
205
|
+
# This loop looks dump, but it is used to suppress the unused parameter configuration warning
|
206
|
+
# Learned from `filter_record_transformer`.
|
207
|
+
conf.elements.select { |element| element.name == 'fields' }.each do |element|
|
208
|
+
element.each_pair { |k, _v| element.key?(k) }
|
209
|
+
end
|
210
|
+
|
211
|
+
return unless @fields
|
212
|
+
|
213
|
+
@extra_fields = @fields.corresponding_config_element.map do |k, v|
|
214
|
+
[k, v.empty? ? k : v]
|
215
|
+
end.to_h
|
216
|
+
end
|
217
|
+
|
218
|
+
def pick_custom_format_method
|
219
|
+
if @data_type == :event
|
220
|
+
define_singleton_method :format, method(:format_event)
|
221
|
+
else
|
222
|
+
define_singleton_method :format, method(:format_metric)
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
def configure_metrics(conf)
|
227
|
+
@metric_labels = {
|
228
|
+
type: conf['@type'],
|
229
|
+
plugin_id: plugin_id
|
230
|
+
}
|
231
|
+
|
232
|
+
@metrics = {
|
233
|
+
record_counter: register_metric(::Prometheus::Client::Counter.new(
|
234
|
+
:splunk_output_write_records_count,
|
235
|
+
'The number of log records being sent'
|
236
|
+
)),
|
237
|
+
bytes_counter: register_metric(::Prometheus::Client::Counter.new(
|
238
|
+
:splunk_output_write_bytes_count,
|
239
|
+
'The number of log bytes being sent'
|
240
|
+
)),
|
241
|
+
status_counter: register_metric(::Prometheus::Client::Counter.new(
|
242
|
+
:splunk_output_write_status_count,
|
243
|
+
'The count of sends by response_code'
|
244
|
+
)),
|
245
|
+
write_bytes_histogram: register_metric(::Prometheus::Client::Histogram.new(
|
246
|
+
:splunk_output_write_payload_bytes,
|
247
|
+
'The size of the write payload in bytes', {}, [1024, 23_937, 47_875, 95_750, 191_500, 383_000, 766_000, 1_149_000]
|
248
|
+
)),
|
249
|
+
write_records_histogram: register_metric(::Prometheus::Client::Histogram.new(
|
250
|
+
:splunk_output_write_payload_records,
|
251
|
+
'The number of records written per write', {}, [1, 10, 25, 100, 200, 300, 500, 750, 1000, 1500]
|
252
|
+
)),
|
253
|
+
write_latency_histogram: register_metric(::Prometheus::Client::Histogram.new(
|
254
|
+
:splunk_output_write_latency_seconds,
|
255
|
+
'The latency of writes'
|
256
|
+
))
|
257
|
+
}
|
258
|
+
end
|
259
|
+
|
260
|
+
# Tag metrics with the type string that was used to register the plugin
|
261
|
+
def metric_labels(other_labels = {})
|
262
|
+
@metric_labels.merge other_labels
|
263
|
+
end
|
264
|
+
|
265
|
+
# Encode as UTF-8. If 'coerce_to_utf8' is set to true in the config, any
|
266
|
+
# non-UTF-8 character would be replaced by the string specified by
|
267
|
+
# 'non_utf8_replacement_string'. If 'coerce_to_utf8' is set to false, any
|
268
|
+
# non-UTF-8 character would trigger the plugin to error out.
|
269
|
+
# Thanks to
|
270
|
+
# https://github.com/GoogleCloudPlatform/fluent-plugin-google-cloud/blob/dbc28575/lib/fluent/plugin/out_google_cloud.rb#L1284
|
271
|
+
def convert_to_utf8(input)
|
272
|
+
if input.is_a?(Hash)
|
273
|
+
record = {}
|
274
|
+
input.each do |key, value|
|
275
|
+
record[convert_to_utf8(key)] = convert_to_utf8(value)
|
276
|
+
end
|
277
|
+
|
278
|
+
return record
|
279
|
+
end
|
280
|
+
return input.map { |value| convert_to_utf8(value) } if input.is_a?(Array)
|
281
|
+
return input unless input.respond_to?(:encode)
|
282
|
+
|
283
|
+
if @coerce_to_utf8
|
284
|
+
input.encode(
|
285
|
+
'utf-8',
|
286
|
+
invalid: :replace,
|
287
|
+
undef: :replace,
|
288
|
+
replace: @non_utf8_replacement_string
|
289
|
+
)
|
290
|
+
else
|
291
|
+
begin
|
292
|
+
input.encode('utf-8')
|
293
|
+
rescue EncodingError
|
294
|
+
log.error do
|
295
|
+
'Encountered encoding issues potentially due to non ' \
|
296
|
+
'UTF-8 characters. To allow non-UTF-8 characters and ' \
|
297
|
+
'replace them with spaces, please set "coerce_to_utf8" ' \
|
298
|
+
'to true.'
|
299
|
+
end
|
300
|
+
raise
|
301
|
+
end
|
302
|
+
end
|
303
|
+
end
|
304
|
+
|
305
|
+
def register_metric(metric)
|
306
|
+
if !@registry.exist?(metric.name)
|
307
|
+
@registry.register(metric)
|
308
|
+
else
|
309
|
+
@registry.get(metric.name)
|
310
|
+
end
|
311
|
+
end
|
312
|
+
end
|
313
|
+
end
|
@@ -1,11 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'fluent/match'
|
2
4
|
|
3
|
-
class Fluent::Plugin::
|
5
|
+
class Fluent::Plugin::SplunkOutput::MatchFormatter
|
4
6
|
def initialize(pattern, formatter)
|
5
7
|
# based on fluentd/lib/fluent/event_router.rb
|
6
|
-
patterns = pattern.split(/\s+/).map
|
8
|
+
patterns = pattern.split(/\s+/).map do |str|
|
7
9
|
Fluent::MatchPattern.create(str)
|
8
|
-
|
10
|
+
end
|
9
11
|
@pattern =
|
10
12
|
if patterns.length == 1
|
11
13
|
patterns[0]
|
@@ -1,14 +1,17 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require
|
4
|
-
require
|
2
|
+
$LOAD_PATH.unshift(File.expand_path('..', __dir__))
|
3
|
+
require 'fluent/env'
|
4
|
+
require 'fluent/output'
|
5
|
+
require 'fluent/plugin/output'
|
6
|
+
require 'fluent/plugin/formatter'
|
7
|
+
require 'fluent/plugin/out_splunk'
|
5
8
|
|
6
9
|
require 'openssl'
|
7
10
|
require 'multi_json'
|
8
11
|
require 'net/http/persistent'
|
9
12
|
|
10
13
|
module Fluent::Plugin
|
11
|
-
class SplunkHecOutput <
|
14
|
+
class SplunkHecOutput < SplunkOutput
|
12
15
|
Fluent::Plugin.register_output('splunk_hec', self)
|
13
16
|
|
14
17
|
helpers :formatter
|
@@ -19,10 +22,10 @@ module Fluent::Plugin
|
|
19
22
|
KEY_FIELDS = %w[index time host source sourcetype metric_name metric_value].freeze
|
20
23
|
TAG_PLACEHOLDER = '${tag}'.freeze
|
21
24
|
|
22
|
-
MISSING_FIELD = Hash.new
|
25
|
+
MISSING_FIELD = Hash.new do |_h, k|
|
23
26
|
$log.warn "expected field #{k} but it's missing" if defined?($log)
|
24
27
|
MISSING_FIELD
|
25
|
-
|
28
|
+
end.freeze
|
26
29
|
|
27
30
|
desc 'Protocol to use to call HEC API.'
|
28
31
|
config_param :protocol, :enum, list: %i[http https], default: :https
|
@@ -69,42 +72,27 @@ module Fluent::Plugin
|
|
69
72
|
desc 'The Splunk index to index events. When not set, will be decided by HEC. This is exclusive with `index_key`'
|
70
73
|
config_param :index, :string, default: nil
|
71
74
|
|
72
|
-
desc 'Field name to contain Splunk event time. By default will use fluentd\'d time'
|
73
|
-
config_param :time_key, :string, default: nil
|
74
|
-
|
75
75
|
desc 'Field name to contain Splunk index name. This is exclusive with `index`.'
|
76
76
|
config_param :index_key, :string, default: nil
|
77
77
|
|
78
|
-
desc "The host field for events, by default it uses the hostname of the machine that runnning fluentd. This is exclusive with `host_key`."
|
79
|
-
config_param :host, :string, default: nil
|
80
|
-
|
81
|
-
desc 'Field name to contain host. This is exclusive with `host`.'
|
82
|
-
config_param :host_key, :string, default: nil
|
83
|
-
|
84
|
-
desc 'The source field for events, when not set, will be decided by HEC. This is exclusive with `source_key`.'
|
85
|
-
config_param :source, :string, default: nil
|
86
|
-
|
87
|
-
desc 'Field name to contain source. This is exclusive with `source`.'
|
88
|
-
config_param :source_key, :string, default: nil
|
89
|
-
|
90
|
-
desc 'The sourcetype field for events, when not set, will be decided by HEC. This is exclusive with `sourcetype_key`.'
|
91
|
-
config_param :sourcetype, :string, default: nil
|
92
|
-
|
93
|
-
desc 'Field name to contain sourcetype. This is exclusive with `sourcetype`.'
|
94
|
-
config_param :sourcetype_key, :string, default: nil
|
95
|
-
|
96
78
|
desc 'When `data_type` is set to "metric", by default it will treat every key-value pair in the income event as a metric name-metric value pair. Set `metrics_from_event` to `false` to disable this behavior and use `metric_name_key` and `metric_value_key` to define metrics.'
|
97
79
|
config_param :metrics_from_event, :bool, default: true
|
98
80
|
|
99
|
-
desc
|
81
|
+
desc 'Field name to contain metric name. This is exclusive with `metrics_from_event`, when this is set, `metrics_from_event` will be set to `false`.'
|
100
82
|
config_param :metric_name_key, :string, default: nil
|
101
83
|
|
102
|
-
desc
|
84
|
+
desc 'Field name to contain metric value, this is required when `metric_name_key` is set.'
|
103
85
|
config_param :metric_value_key, :string, default: nil
|
104
86
|
|
105
87
|
desc 'When set to true, all fields defined in `index_key`, `host_key`, `source_key`, `sourcetype_key`, `metric_name_key`, `metric_value_key` will not be removed from the original event.'
|
106
88
|
config_param :keep_keys, :bool, default: false
|
107
89
|
|
90
|
+
desc 'App name'
|
91
|
+
config_param :app_name, :string, default: "hec_plugin_gem"
|
92
|
+
|
93
|
+
desc 'App version'
|
94
|
+
config_param :app_version, :string, default: "#{VERSION}"
|
95
|
+
|
108
96
|
desc 'Define index-time fields for event data type, or metric dimensions for metric data type. Null value fields will be removed.'
|
109
97
|
config_section :fields, init: false, multi: false, required: false do
|
110
98
|
# this is blank on purpose
|
@@ -139,47 +127,45 @@ module Fluent::Plugin
|
|
139
127
|
def configure(conf)
|
140
128
|
super
|
141
129
|
|
142
|
-
check_conflict
|
143
130
|
check_metric_configs
|
144
|
-
construct_api
|
145
|
-
prepare_key_fields
|
146
|
-
configure_fields(conf)
|
147
131
|
pick_custom_format_method
|
148
|
-
|
149
|
-
# @formatter_configs is from formatter helper
|
150
|
-
@formatters = @formatter_configs.map { |section|
|
151
|
-
MatchFormatter.new section.usage, formatter_create(usage: section.usage)
|
152
|
-
}
|
153
132
|
end
|
154
133
|
|
155
134
|
def start
|
156
135
|
super
|
136
|
+
@conn = Net::HTTP::Persistent.new.tap do |c|
|
137
|
+
c.verify_mode = @insecure_ssl ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
|
138
|
+
c.cert = OpenSSL::X509::Certificate.new File.read(@client_cert) if @client_cert
|
139
|
+
c.key = OpenSSL::PKey::RSA.new File.read(@client_key) if @client_key
|
140
|
+
c.ca_file = @ca_file
|
141
|
+
c.ca_path = @ca_path
|
142
|
+
c.ciphers = @ssl_ciphers
|
143
|
+
|
144
|
+
c.override_headers['Content-Type'] = 'application/json'
|
145
|
+
c.override_headers['User-Agent'] = "fluent-plugin-splunk_hec_out/#{VERSION}"
|
146
|
+
c.override_headers['Authorization'] = "Splunk #{@hec_token}"
|
147
|
+
c.override_headers['__splunk_app_name'] = "#{@app_name}"
|
148
|
+
c.override_headers['__splunk_app_version'] = "#{@app_version}"
|
157
149
|
|
158
|
-
|
150
|
+
end
|
159
151
|
end
|
160
152
|
|
161
|
-
def
|
162
|
-
|
153
|
+
def shutdown
|
154
|
+
super
|
155
|
+
@conn.shutdown
|
163
156
|
end
|
164
157
|
|
165
|
-
def
|
166
|
-
|
167
|
-
send_to_hec chunk
|
158
|
+
def format(tag, time, record)
|
159
|
+
# this method will be replaced in `configure`
|
168
160
|
end
|
169
161
|
|
170
162
|
def multi_workers_ready?
|
171
163
|
true
|
172
164
|
end
|
173
165
|
|
174
|
-
|
166
|
+
protected
|
175
167
|
|
176
|
-
|
177
|
-
KEY_FIELDS.each { |f|
|
178
|
-
kf = "#{f}_key"
|
179
|
-
raise Fluent::ConfigError, "Can not set #{f} and #{kf} at the same time." \
|
180
|
-
if %W[@#{f} @#{kf}].all? &method(:instance_variable_get)
|
181
|
-
}
|
182
|
-
end
|
168
|
+
private
|
183
169
|
|
184
170
|
def check_metric_configs
|
185
171
|
return unless @data_type == :metric
|
@@ -188,71 +174,57 @@ module Fluent::Plugin
|
|
188
174
|
|
189
175
|
return if @metrics_from_event
|
190
176
|
|
191
|
-
raise Fluent::ConfigError,
|
192
|
-
|
193
|
-
raise Fluent::ConfigError, "`metric_value_key` is required when `metric_name_key` is set." unless @metric_value_key
|
194
|
-
end
|
195
|
-
|
196
|
-
def prepare_key_fields
|
197
|
-
KEY_FIELDS.each { |f|
|
198
|
-
v = instance_variable_get "@#{f}_key"
|
199
|
-
if v
|
200
|
-
attrs = v.split('.').freeze
|
201
|
-
if @keep_keys
|
202
|
-
instance_variable_set "@#{f}", ->(_, record) { attrs.inject(record) { |o, k| o[k] } }
|
203
|
-
else
|
204
|
-
instance_variable_set "@#{f}", ->(_, record) {
|
205
|
-
attrs[0...-1].inject(record) { |o, k| o[k] }.delete(attrs[-1])
|
206
|
-
}
|
207
|
-
end
|
208
|
-
else
|
209
|
-
v = instance_variable_get "@#{f}"
|
210
|
-
next unless v
|
211
|
-
|
212
|
-
if v == TAG_PLACEHOLDER
|
213
|
-
instance_variable_set "@#{f}", ->(tag, _) { tag }
|
214
|
-
else
|
215
|
-
instance_variable_set "@#{f}", ->(_, _) { v }
|
216
|
-
end
|
217
|
-
end
|
218
|
-
}
|
177
|
+
raise Fluent::ConfigError, '`metric_name_key` is required when `metrics_from_event` is `false`.' unless @metric_name_key
|
178
|
+
raise Fluent::ConfigError, '`metric_value_key` is required when `metric_name_key` is set.' unless @metric_value_key
|
219
179
|
end
|
220
180
|
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
181
|
+
def format_event(tag, time, record)
|
182
|
+
MultiJson.dump({
|
183
|
+
host: @host ? @host.(tag, record) : @default_host,
|
184
|
+
# From the API reference
|
185
|
+
# http://docs.splunk.com/Documentation/Splunk/latest/RESTREF/RESTinput#services.2Fcollector
|
186
|
+
# `time` should be a string or unsigned integer.
|
187
|
+
# That's why we use the to_string function here.
|
188
|
+
time: time.to_f.to_s
|
189
|
+
}.tap { |payload|
|
190
|
+
if @time
|
191
|
+
time_value = @time.(tag, record)
|
192
|
+
# if no value is found don't override and use fluentd's time
|
193
|
+
if !time_value.nil?
|
194
|
+
payload[:time] = time_value
|
195
|
+
end
|
196
|
+
end
|
230
197
|
|
231
|
-
|
198
|
+
payload[:index] = @index.(tag, record) if @index
|
199
|
+
payload[:source] = @source.(tag, record) if @source
|
200
|
+
payload[:sourcetype] = @sourcetype.(tag, record) if @sourcetype
|
232
201
|
|
233
|
-
|
234
|
-
|
235
|
-
}.to_h
|
236
|
-
end
|
202
|
+
# delete nil fields otherwise will get formet error from HEC
|
203
|
+
%i[host index source sourcetype].each { |f| payload.delete f if payload[f].nil? }
|
237
204
|
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
205
|
+
if @extra_fields
|
206
|
+
payload[:fields] = @extra_fields.map { |name, field| [name, record[field]] }.to_h
|
207
|
+
payload[:fields].delete_if { |_k,v| v.nil? }
|
208
|
+
# if a field is already in indexed fields, then remove it from the original event
|
209
|
+
@extra_fields.values.each { |field| record.delete field }
|
210
|
+
end
|
211
|
+
if formatter = @formatters.find { |f| f.match? tag }
|
212
|
+
record = formatter.format(tag, time, record)
|
213
|
+
end
|
214
|
+
payload[:event] = convert_to_utf8 record
|
215
|
+
})
|
244
216
|
end
|
245
217
|
|
246
|
-
def
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
218
|
+
def format_metric(tag, time, record)
|
219
|
+
payload = {
|
220
|
+
host: @host ? @host.call(tag, record) : @default_host,
|
221
|
+
# From the API reference
|
222
|
+
# http://docs.splunk.com/Documentation/Splunk/latest/RESTREF/RESTinput#services.2Fcollector
|
223
|
+
# `time` should be a string or unsigned integer.
|
224
|
+
# That's why we use `to_s` here.
|
225
|
+
time: time.to_f.to_s,
|
226
|
+
event: 'metric'
|
227
|
+
}.tap do |payload|
|
256
228
|
if @time
|
257
229
|
time_value = @time.(tag, record)
|
258
230
|
# if no value is found don't override and use fluentd's time
|
@@ -260,71 +232,42 @@ module Fluent::Plugin
|
|
260
232
|
payload[:time] = time_value
|
261
233
|
end
|
262
234
|
end
|
235
|
+
end
|
236
|
+
payload[:index] = @index.call(tag, record) if @index
|
237
|
+
payload[:source] = @source.call(tag, record) if @source
|
238
|
+
payload[:sourcetype] = @sourcetype.call(tag, record) if @sourcetype
|
239
|
+
|
240
|
+
unless @metrics_from_event
|
241
|
+
fields = {
|
242
|
+
metric_name: @metric_name.call(tag, record),
|
243
|
+
_value: @metric_value.call(tag, record)
|
244
|
+
}
|
245
|
+
|
246
|
+
if @extra_fields
|
247
|
+
fields.update @extra_fields.map { |name, field| [name, record[field]] }.to_h
|
248
|
+
fields.delete_if { |_k,v| v.nil? }
|
249
|
+
else
|
250
|
+
fields.update record
|
251
|
+
end
|
263
252
|
|
264
|
-
|
265
|
-
payload[:source] = @source.(tag, record) if @source
|
266
|
-
payload[:sourcetype] = @sourcetype.(tag, record) if @sourcetype
|
267
|
-
|
268
|
-
# delete nil fields otherwise will get formet error from HEC
|
269
|
-
%i[host index source sourcetype].each { |f| payload.delete f if payload[f].nil? }
|
270
|
-
|
271
|
-
if @extra_fields
|
272
|
-
payload[:fields] = @extra_fields.map { |name, field| [name, record[field]] }.to_h
|
273
|
-
payload[:fields].delete_if { |_k,v| v.nil? }
|
274
|
-
# if a field is already in indexed fields, then remove it from the original event
|
275
|
-
@extra_fields.values.each { |field| record.delete field }
|
276
|
-
end
|
277
|
-
if formatter = @formatters.find { |f| f.match? tag }
|
278
|
-
record = formatter.format(tag, time, record)
|
279
|
-
end
|
280
|
-
payload[:event] = convert_to_utf8 record
|
281
|
-
})
|
282
|
-
end
|
253
|
+
fields.delete_if { |_k,v| v.nil? }
|
283
254
|
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
# From the API reference
|
288
|
-
# http://docs.splunk.com/Documentation/Splunk/latest/RESTREF/RESTinput#services.2Fcollector
|
289
|
-
# `time` should be a string or unsigned integer.
|
290
|
-
# That's why we use `to_s` here.
|
291
|
-
time: time.to_f.to_s,
|
292
|
-
event: 'metric'
|
293
|
-
}
|
294
|
-
payload[:index] = @index.(tag, record) if @index
|
295
|
-
payload[:source] = @source.(tag, record) if @source
|
296
|
-
payload[:sourcetype] = @sourcetype.(tag, record) if @sourcetype
|
297
|
-
|
298
|
-
if not @metrics_from_event
|
299
|
-
fields = {
|
300
|
-
metric_name: @metric_name.(tag, record),
|
301
|
-
_value: @metric_value.(tag, record)
|
302
|
-
}
|
303
|
-
|
304
|
-
if @extra_fields
|
305
|
-
fields.update @extra_fields.map { |name, field| [name, record[field]] }.to_h
|
306
|
-
else
|
307
|
-
fields.update record
|
308
|
-
end
|
309
|
-
|
310
|
-
fields.delete_if { |_k,v| v.nil? }
|
311
|
-
|
312
|
-
payload[:fields] = convert_to_utf8 fields
|
313
|
-
|
314
|
-
return MultiJson.dump(payload)
|
255
|
+
payload[:fields] = convert_to_utf8 fields
|
256
|
+
|
257
|
+
return MultiJson.dump(payload)
|
315
258
|
end
|
316
259
|
|
317
260
|
# when metrics_from_event is true, generate one metric event for each key-value in record
|
318
|
-
payloads = record.map
|
319
|
-
|
320
|
-
|
261
|
+
payloads = record.map do |key, value|
|
262
|
+
{ fields: { metric_name: key, _value: value } }.merge! payload
|
263
|
+
end
|
321
264
|
|
322
265
|
payloads.map!(&MultiJson.method(:dump)).join
|
323
266
|
end
|
324
267
|
|
325
268
|
def construct_api
|
326
|
-
|
327
|
-
rescue
|
269
|
+
URI("#{@protocol}://#{@hec_host}:#{@hec_port}/services/collector")
|
270
|
+
rescue StandardError
|
328
271
|
raise Fluent::ConfigError, "hec_host (#{@hec_host}) and/or hec_port (#{@hec_port}) are invalid."
|
329
272
|
end
|
330
273
|
|
@@ -343,27 +286,34 @@ module Fluent::Plugin
|
|
343
286
|
c.override_headers['Content-Type'] = 'application/json'
|
344
287
|
c.override_headers['User-Agent'] = "fluent-plugin-splunk_hec_out/#{VERSION}"
|
345
288
|
c.override_headers['Authorization'] = "Splunk #{@hec_token}"
|
289
|
+
c.override_headers['__splunk_app_name'] = "#{@app_name}"
|
290
|
+
c.override_headers['__splunk_app_version'] = "#{@app_version}"
|
291
|
+
|
346
292
|
end
|
347
293
|
end
|
348
294
|
|
349
|
-
def
|
350
|
-
post = Net::HTTP::Post.new @
|
295
|
+
def write_to_splunk(chunk)
|
296
|
+
post = Net::HTTP::Post.new @api.request_uri
|
351
297
|
post.body = chunk.read
|
352
|
-
log.debug { "Sending #{post.body.bytesize}
|
298
|
+
log.debug { "[Sending] Chunk: #{dump_unique_id_hex(chunk.unique_id)}(#{post.body.bytesize}B)." }
|
299
|
+
log.trace { "POST #{@api} body=#{post.body}" }
|
353
300
|
|
354
|
-
|
355
|
-
response = @
|
356
|
-
|
301
|
+
t1 = Time.now
|
302
|
+
response = @conn.request @api, post
|
303
|
+
t2 = Time.now
|
357
304
|
|
358
305
|
# raise Exception to utilize Fluentd output plugin retry machanism
|
359
|
-
raise "Server error (#{response.code}) for POST #{@
|
306
|
+
raise "Server error (#{response.code}) for POST #{@api}, response: #{response.body}" if response.code.start_with?('5')
|
360
307
|
|
361
308
|
# For both success response (2xx) and client errors (4xx), we will consume the chunk.
|
362
309
|
# Because there probably a bug in the code if we get 4xx errors, retry won't do any good.
|
363
310
|
if not response.code.start_with?('2')
|
364
|
-
|
365
|
-
|
311
|
+
log.error "Failed POST to #{@api}, response: #{response.body}"
|
312
|
+
log.debug { "Failed request body: #{post.body}" }
|
366
313
|
end
|
314
|
+
|
315
|
+
log.debug { "[Response] Chunk: #{dump_unique_id_hex(chunk.unique_id)} Size: #{post.body.bytesize} Response: #{response.inspect} Duration: #{t2 - t1}" }
|
316
|
+
process_response(response, post.body)
|
367
317
|
end
|
368
318
|
|
369
319
|
# Encode as UTF-8. If 'coerce_to_utf8' is set to true in the config, any
|
@@ -374,32 +324,32 @@ module Fluent::Plugin
|
|
374
324
|
# https://github.com/GoogleCloudPlatform/fluent-plugin-google-cloud/blob/dbc28575/lib/fluent/plugin/out_google_cloud.rb#L1284
|
375
325
|
def convert_to_utf8(input)
|
376
326
|
if input.is_a?(Hash)
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
|
327
|
+
record = {}
|
328
|
+
input.each do |key, value|
|
329
|
+
record[convert_to_utf8(key)] = convert_to_utf8(value)
|
330
|
+
end
|
381
331
|
|
382
|
-
|
332
|
+
return record
|
383
333
|
end
|
384
334
|
return input.map { |value| convert_to_utf8(value) } if input.is_a?(Array)
|
385
335
|
return input unless input.respond_to?(:encode)
|
386
336
|
|
387
337
|
if @coerce_to_utf8
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
338
|
+
input.encode(
|
339
|
+
'utf-8',
|
340
|
+
invalid: :replace,
|
341
|
+
undef: :replace,
|
342
|
+
replace: @non_utf8_replacement_string)
|
343
|
+
else
|
344
|
+
begin
|
345
|
+
input.encode('utf-8')
|
346
|
+
rescue EncodingError
|
347
|
+
log.error { 'Encountered encoding issues potentially due to non ' \
|
348
|
+
'UTF-8 characters. To allow non-UTF-8 characters and ' \
|
349
|
+
'replace them with spaces, please set "coerce_to_utf8" ' \
|
350
|
+
'to true.' }
|
351
|
+
raise
|
352
|
+
end
|
403
353
|
end
|
404
354
|
end
|
405
355
|
end
|