fluent-plugin-splunk-hec-radiant 0.1.0
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 +7 -0
- data/Gemfile +11 -0
- data/LICENSE +301 -0
- data/NOTICE +18 -0
- data/README.md +315 -0
- data/Rakefile +10 -0
- data/fluent-plugin-splunk-hec-radiant.gemspec +53 -0
- data/lib/fluent/plugin/out_splunk_hec_radiant.rb +555 -0
- data/lib/fluent/plugin/splunk_hec_radiant/match_formatter.rb +49 -0
- data/lib/fluent/plugin/splunk_hec_radiant/version.rb +9 -0
- data/lib/fluent-plugin-splunk-hec-radiant.rb +4 -0
- metadata +248 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/fluent/plugin/splunk_hec_radiant/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "fluent-plugin-splunk-hec-radiant"
|
|
7
|
+
spec.version = Fluent::Plugin::SplunkHecRadiant::VERSION
|
|
8
|
+
spec.authors = ["G. Rahul Nutakki"]
|
|
9
|
+
spec.email = ["gnanirn@gmail.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Modernized Fluentd output plugin for Splunk HEC"
|
|
12
|
+
spec.description = "A modernized and actively maintained Fluentd output plugin for " \
|
|
13
|
+
"Splunk HTTP Event Collector (HEC) with updated dependencies, " \
|
|
14
|
+
"improved performance, and Ruby 3.x support. Forked from the " \
|
|
15
|
+
"original Splunk plugin with enhancements and bug fixes."
|
|
16
|
+
spec.homepage = "https://github.com/gnanirahulnutakki/fluent-plugin-splunk-hec-radiant"
|
|
17
|
+
spec.license = "Apache-2.0"
|
|
18
|
+
spec.required_ruby_version = ">= 3.0.0"
|
|
19
|
+
|
|
20
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
21
|
+
spec.metadata["source_code_uri"] = "https://github.com/gnanirahulnutakki/fluent-plugin-splunk-hec-radiant"
|
|
22
|
+
spec.metadata["changelog_uri"] = "https://github.com/gnanirahulnutakki/fluent-plugin-splunk-hec-radiant/blob/main/CHANGELOG.md"
|
|
23
|
+
spec.metadata["bug_tracker_uri"] = "https://github.com/gnanirahulnutakki/fluent-plugin-splunk-hec-radiant/issues"
|
|
24
|
+
spec.metadata["documentation_uri"] = "https://github.com/gnanirahulnutakki/fluent-plugin-splunk-hec-radiant/blob/main/README.md"
|
|
25
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
26
|
+
|
|
27
|
+
# Specify which files should be added to the gem when it is released.
|
|
28
|
+
spec.files = Dir.glob("lib/**/*") + %w[
|
|
29
|
+
README.md
|
|
30
|
+
LICENSE
|
|
31
|
+
NOTICE
|
|
32
|
+
fluent-plugin-splunk-hec-radiant.gemspec
|
|
33
|
+
Gemfile
|
|
34
|
+
Rakefile
|
|
35
|
+
]
|
|
36
|
+
spec.require_paths = ["lib"]
|
|
37
|
+
|
|
38
|
+
# Runtime dependencies - modernized versions
|
|
39
|
+
spec.add_dependency "fluentd", ">= 1.16", "< 2.0"
|
|
40
|
+
spec.add_dependency "net-http-persistent", ">= 4.0", "< 6.0"
|
|
41
|
+
spec.add_dependency "oj", "~> 3.16"
|
|
42
|
+
spec.add_dependency "prometheus-client", ">= 2.1.0", "< 4.0"
|
|
43
|
+
|
|
44
|
+
# Development dependencies
|
|
45
|
+
spec.add_development_dependency "bundler", ">= 2.0"
|
|
46
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
47
|
+
spec.add_development_dependency "rspec", "~> 3.13"
|
|
48
|
+
spec.add_development_dependency "rubocop", "~> 1.60"
|
|
49
|
+
spec.add_development_dependency "rubocop-rspec", "~> 3.0"
|
|
50
|
+
spec.add_development_dependency "simplecov", "~> 0.22"
|
|
51
|
+
spec.add_development_dependency "test-unit", "~> 3.6"
|
|
52
|
+
spec.add_development_dependency "webmock", "~> 3.23"
|
|
53
|
+
end
|
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright 2018 Splunk Inc.
|
|
4
|
+
# Modifications Copyright 2025 G. Rahul Nutakki
|
|
5
|
+
#
|
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
# you may not use this file except in compliance with the License.
|
|
8
|
+
# You may obtain a copy of the License at
|
|
9
|
+
#
|
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
#
|
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
# See the License for the specific language governing permissions and
|
|
16
|
+
# limitations under the License.
|
|
17
|
+
|
|
18
|
+
require "fluent/plugin/output"
|
|
19
|
+
require "fluent/plugin/formatter"
|
|
20
|
+
require "fluent/plugin/splunk_hec_radiant/version"
|
|
21
|
+
require "fluent/plugin/splunk_hec_radiant/match_formatter"
|
|
22
|
+
require "net/http/persistent"
|
|
23
|
+
require "openssl"
|
|
24
|
+
require "oj"
|
|
25
|
+
require "zlib"
|
|
26
|
+
require "socket"
|
|
27
|
+
require "benchmark"
|
|
28
|
+
require "prometheus/client"
|
|
29
|
+
|
|
30
|
+
module Fluent
|
|
31
|
+
module Plugin
|
|
32
|
+
# Modernized Splunk HEC output plugin
|
|
33
|
+
class SplunkHecRadiantOutput < Fluent::Plugin::Output
|
|
34
|
+
Fluent::Plugin.register_output("splunk_hec_radiant", self)
|
|
35
|
+
|
|
36
|
+
helpers :formatter
|
|
37
|
+
|
|
38
|
+
KEY_FIELDS = %w[index time host source sourcetype metric_name metric_value].freeze
|
|
39
|
+
TAG_PLACEHOLDER = "${tag}"
|
|
40
|
+
|
|
41
|
+
MISSING_FIELD = Hash.new do |_h, k|
|
|
42
|
+
$log.warn "expected field #{k} but it's missing" if defined?($log)
|
|
43
|
+
MISSING_FIELD
|
|
44
|
+
end.freeze
|
|
45
|
+
|
|
46
|
+
desc "Protocol to use to call HEC API."
|
|
47
|
+
config_param :protocol, :enum, list: %i[http https], default: :https
|
|
48
|
+
|
|
49
|
+
desc "The hostname/IP to HEC, or HEC load balancer."
|
|
50
|
+
config_param :hec_host, :string, default: ""
|
|
51
|
+
|
|
52
|
+
desc "The port number to HEC, or HEC load balancer."
|
|
53
|
+
config_param :hec_port, :integer, default: 8088
|
|
54
|
+
|
|
55
|
+
desc "HEC REST API endpoint to use"
|
|
56
|
+
config_param :hec_endpoint, :string, default: "services/collector"
|
|
57
|
+
|
|
58
|
+
desc "Full url to connect to splunk. Example: https://mydomain.com:8088/apps/splunk"
|
|
59
|
+
config_param :full_url, :string, default: ""
|
|
60
|
+
|
|
61
|
+
desc "The HEC token."
|
|
62
|
+
config_param :hec_token, :string, secret: true
|
|
63
|
+
|
|
64
|
+
desc "If a connection has not been used for this number of seconds it will automatically be reset."
|
|
65
|
+
config_param :idle_timeout, :integer, default: 5
|
|
66
|
+
|
|
67
|
+
desc "The amount of time allowed between reading two chunks from the socket."
|
|
68
|
+
config_param :read_timeout, :integer, default: nil
|
|
69
|
+
|
|
70
|
+
desc "The amount of time to wait for a connection to be opened."
|
|
71
|
+
config_param :open_timeout, :integer, default: nil
|
|
72
|
+
|
|
73
|
+
desc "The path to a file containing a PEM-format CA certificate for this client."
|
|
74
|
+
config_param :client_cert, :string, default: nil
|
|
75
|
+
|
|
76
|
+
desc "The private key for this client."
|
|
77
|
+
config_param :client_key, :string, default: nil
|
|
78
|
+
|
|
79
|
+
desc "The path to a file containing a PEM-format CA certificate."
|
|
80
|
+
config_param :ca_file, :string, default: nil
|
|
81
|
+
|
|
82
|
+
desc "The path to a directory containing CA certificates in PEM format."
|
|
83
|
+
config_param :ca_path, :string, default: nil
|
|
84
|
+
|
|
85
|
+
desc "List of SSL ciphers allowed."
|
|
86
|
+
config_param :ssl_ciphers, :array, default: nil
|
|
87
|
+
|
|
88
|
+
desc "When set to true, TLS version 1.2 and above is required."
|
|
89
|
+
config_param :require_ssl_min_version, :bool, default: true
|
|
90
|
+
|
|
91
|
+
desc "Indicates if insecure SSL connection is allowed."
|
|
92
|
+
config_param :insecure_ssl, :bool, default: false
|
|
93
|
+
|
|
94
|
+
desc "Type of data sending to Splunk, `event` or `metric`."
|
|
95
|
+
config_param :data_type, :enum, list: %i[event metric], default: :event
|
|
96
|
+
|
|
97
|
+
desc "The Splunk index to index events."
|
|
98
|
+
config_param :index, :string, default: nil
|
|
99
|
+
|
|
100
|
+
desc "Field name to contain Splunk index name."
|
|
101
|
+
config_param :index_key, :string, default: nil
|
|
102
|
+
|
|
103
|
+
desc "The host field for events."
|
|
104
|
+
config_param :host, :string, default: nil
|
|
105
|
+
|
|
106
|
+
desc "Field name to contain host."
|
|
107
|
+
config_param :host_key, :string, default: nil
|
|
108
|
+
|
|
109
|
+
desc "The source field for events."
|
|
110
|
+
config_param :source, :string, default: nil
|
|
111
|
+
|
|
112
|
+
desc "Field name to contain source."
|
|
113
|
+
config_param :source_key, :string, default: nil
|
|
114
|
+
|
|
115
|
+
desc "The sourcetype field for events."
|
|
116
|
+
config_param :sourcetype, :string, default: nil
|
|
117
|
+
|
|
118
|
+
desc "Field name to contain sourcetype."
|
|
119
|
+
config_param :sourcetype_key, :string, default: nil
|
|
120
|
+
|
|
121
|
+
desc "Field name to contain Splunk event time."
|
|
122
|
+
config_param :time_key, :string, default: nil
|
|
123
|
+
|
|
124
|
+
desc "When data_type is metric, use metrics_from_event mode."
|
|
125
|
+
config_param :metrics_from_event, :bool, default: true
|
|
126
|
+
|
|
127
|
+
desc "Field name to contain metric name."
|
|
128
|
+
config_param :metric_name_key, :string, default: nil
|
|
129
|
+
|
|
130
|
+
desc "Field name to contain metric value."
|
|
131
|
+
config_param :metric_value_key, :string, default: nil
|
|
132
|
+
|
|
133
|
+
desc "When set to true, defined key fields will not be removed from the original event."
|
|
134
|
+
config_param :keep_keys, :bool, default: false
|
|
135
|
+
|
|
136
|
+
desc "Indicates if GZIP Compression is enabled."
|
|
137
|
+
config_param :gzip_compression, :bool, default: false
|
|
138
|
+
|
|
139
|
+
desc "App name"
|
|
140
|
+
config_param :app_name, :string, default: "fluent_plugin_splunk_hec_radiant"
|
|
141
|
+
|
|
142
|
+
desc "App version"
|
|
143
|
+
config_param :app_version, :string, default: SplunkHecRadiant::VERSION
|
|
144
|
+
|
|
145
|
+
desc "Define index-time fields for event data type, or metric dimensions for metric data type."
|
|
146
|
+
config_section :fields, init: false, multi: false, required: false do
|
|
147
|
+
# this is blank on purpose
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
desc "Indicates if 4xx errors should consume chunk"
|
|
151
|
+
config_param :consume_chunk_on_4xx_errors, :bool, default: true
|
|
152
|
+
|
|
153
|
+
config_section :format do
|
|
154
|
+
config_set_default :usage, "**"
|
|
155
|
+
config_set_default :@type, "json"
|
|
156
|
+
config_set_default :add_newline, false
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
desc "Whether to allow non-UTF-8 characters in user logs."
|
|
160
|
+
config_param :coerce_to_utf8, :bool, default: true
|
|
161
|
+
|
|
162
|
+
desc "If coerce_to_utf8 is true, non-UTF-8 chars are replaced with this string."
|
|
163
|
+
config_param :non_utf8_replacement_string, :string, default: " "
|
|
164
|
+
|
|
165
|
+
desc "Any custom headers to include alongside requests made to Splunk"
|
|
166
|
+
config_param :custom_headers, :hash, default: {}
|
|
167
|
+
|
|
168
|
+
def initialize
|
|
169
|
+
super
|
|
170
|
+
@default_host = Socket.gethostname
|
|
171
|
+
@extra_fields = nil
|
|
172
|
+
@registry = ::Prometheus::Client.registry
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def configure(conf)
|
|
176
|
+
super
|
|
177
|
+
if @hec_host.empty? && @full_url.empty?
|
|
178
|
+
raise Fluent::ConfigError,
|
|
179
|
+
"One of `hec_host` or `full_url` is required."
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
check_conflict
|
|
183
|
+
check_metric_configs
|
|
184
|
+
@api = construct_api
|
|
185
|
+
prepare_key_fields
|
|
186
|
+
configure_fields(conf)
|
|
187
|
+
configure_metrics(conf)
|
|
188
|
+
pick_custom_format_method
|
|
189
|
+
|
|
190
|
+
# @formatter_configs is from formatter helper
|
|
191
|
+
@formatters = @formatter_configs.map do |section|
|
|
192
|
+
SplunkHecRadiant::MatchFormatter.new(section.usage, formatter_create(usage: section.usage))
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def start
|
|
197
|
+
super
|
|
198
|
+
@conn = Net::HTTP::Persistent.new.tap do |c|
|
|
199
|
+
c.verify_mode = @insecure_ssl ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
|
|
200
|
+
c.cert = OpenSSL::X509::Certificate.new File.read(@client_cert) if @client_cert
|
|
201
|
+
c.key = OpenSSL::PKey::RSA.new File.read(@client_key) if @client_key
|
|
202
|
+
c.ca_file = @ca_file
|
|
203
|
+
c.ca_path = @ca_path
|
|
204
|
+
c.ciphers = @ssl_ciphers
|
|
205
|
+
c.proxy = :ENV
|
|
206
|
+
c.idle_timeout = @idle_timeout
|
|
207
|
+
c.read_timeout = @read_timeout
|
|
208
|
+
c.open_timeout = @open_timeout
|
|
209
|
+
c.min_version = OpenSSL::SSL::TLS1_2_VERSION if @require_ssl_min_version
|
|
210
|
+
|
|
211
|
+
c.override_headers["Content-Type"] = "application/json"
|
|
212
|
+
c.override_headers["User-Agent"] = "fluent-plugin-splunk-hec-radiant/#{SplunkHecRadiant::VERSION}"
|
|
213
|
+
c.override_headers["Authorization"] = "Splunk #{@hec_token}"
|
|
214
|
+
c.override_headers["__splunk_app_name"] = @app_name.to_s
|
|
215
|
+
c.override_headers["__splunk_app_version"] = @app_version.to_s
|
|
216
|
+
@custom_headers.each do |header, value|
|
|
217
|
+
c.override_headers[header] = value
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def shutdown
|
|
223
|
+
@conn&.shutdown
|
|
224
|
+
super
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def format(tag, time, record)
|
|
228
|
+
# this method will be replaced in `configure`
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def write(chunk)
|
|
232
|
+
log.trace { "#{self.class}: Received new chunk, size=#{chunk.read.bytesize}" }
|
|
233
|
+
|
|
234
|
+
t = Benchmark.realtime do
|
|
235
|
+
write_to_splunk(chunk)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
@metrics[:record_counter].increment(labels: metric_labels, by: chunk.size)
|
|
239
|
+
@metrics[:bytes_counter].increment(labels: metric_labels, by: chunk.bytesize)
|
|
240
|
+
@metrics[:write_records_histogram].observe(chunk.size, labels: metric_labels)
|
|
241
|
+
@metrics[:write_bytes_histogram].observe(chunk.bytesize, labels: metric_labels)
|
|
242
|
+
@metrics[:write_latency_histogram].observe(t, labels: metric_labels)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def multi_workers_ready?
|
|
246
|
+
true
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
def check_conflict
|
|
252
|
+
KEY_FIELDS.each do |f|
|
|
253
|
+
kf = "#{f}_key"
|
|
254
|
+
next unless instance_variable_get("@#{f}") && instance_variable_get("@#{kf}")
|
|
255
|
+
|
|
256
|
+
raise Fluent::ConfigError, "Can not set #{f} and #{kf} at the same time."
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def check_metric_configs
|
|
261
|
+
return unless @data_type == :metric
|
|
262
|
+
|
|
263
|
+
@metrics_from_event = false if @metric_name_key
|
|
264
|
+
|
|
265
|
+
return if @metrics_from_event
|
|
266
|
+
|
|
267
|
+
unless @metric_name_key
|
|
268
|
+
raise Fluent::ConfigError,
|
|
269
|
+
"`metric_name_key` is required when `metrics_from_event` is `false`."
|
|
270
|
+
end
|
|
271
|
+
return if @metric_value_key
|
|
272
|
+
|
|
273
|
+
raise Fluent::ConfigError,
|
|
274
|
+
"`metric_value_key` is required when `metric_name_key` is set."
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def prepare_key_fields
|
|
278
|
+
KEY_FIELDS.each do |f|
|
|
279
|
+
v = instance_variable_get "@#{f}_key"
|
|
280
|
+
if v
|
|
281
|
+
attrs = v.split(".").freeze
|
|
282
|
+
if @keep_keys
|
|
283
|
+
instance_variable_set "@#{f}", ->(_, record) { attrs.inject(record) { |o, k| o[k] } }
|
|
284
|
+
else
|
|
285
|
+
instance_variable_set "@#{f}", lambda { |_, record|
|
|
286
|
+
attrs[0...-1].inject(record) { |o, k| o[k] }.delete(attrs[-1])
|
|
287
|
+
}
|
|
288
|
+
end
|
|
289
|
+
else
|
|
290
|
+
v = instance_variable_get "@#{f}"
|
|
291
|
+
next unless v
|
|
292
|
+
|
|
293
|
+
if v.include? TAG_PLACEHOLDER
|
|
294
|
+
instance_variable_set "@#{f}", ->(tag, _) { v.gsub(TAG_PLACEHOLDER, tag) }
|
|
295
|
+
else
|
|
296
|
+
instance_variable_set "@#{f}", ->(_, _) { v }
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def configure_fields(conf)
|
|
303
|
+
# This loop looks dumb, but it is used to suppress the unused parameter configuration warning
|
|
304
|
+
conf.elements.select { |element| element.name == "fields" }.each do |element|
|
|
305
|
+
element.each_pair { |k, _v| element.key?(k) }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
return unless @fields
|
|
309
|
+
|
|
310
|
+
@extra_fields = @fields.corresponding_config_element.to_h do |k, v|
|
|
311
|
+
[k, v.empty? ? k : v]
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def pick_custom_format_method
|
|
316
|
+
if @data_type == :event
|
|
317
|
+
define_singleton_method :format, method(:format_event)
|
|
318
|
+
else
|
|
319
|
+
define_singleton_method :format, method(:format_metric)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def format_event(tag, time, record)
|
|
324
|
+
payload = {
|
|
325
|
+
host: @host ? @host.call(tag, record) : @default_host,
|
|
326
|
+
time: time.to_f.to_s
|
|
327
|
+
}.tap do |p|
|
|
328
|
+
if @time
|
|
329
|
+
time_value = @time.call(tag, record)
|
|
330
|
+
p[:time] = time_value unless time_value.nil?
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
p[:index] = @index.call(tag, record) if @index
|
|
334
|
+
p[:source] = @source.call(tag, record) if @source
|
|
335
|
+
p[:sourcetype] = @sourcetype.call(tag, record) if @sourcetype
|
|
336
|
+
|
|
337
|
+
# delete nil fields otherwise will get format error from HEC
|
|
338
|
+
%i[host index source sourcetype].each { |field| p.delete field if p[field].nil? }
|
|
339
|
+
|
|
340
|
+
if @extra_fields
|
|
341
|
+
p[:fields] = @extra_fields.transform_values { |field| record[field] }
|
|
342
|
+
p[:fields].delete_if { |_k, v| v.nil? }
|
|
343
|
+
# if a field is already in indexed fields, then remove it from the original event
|
|
344
|
+
@extra_fields.each_value { |field| record.delete field }
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
formatter = @formatters.find { |f| f.match? tag }
|
|
348
|
+
record = formatter.format(tag, time, record) if formatter
|
|
349
|
+
|
|
350
|
+
p[:event] = convert_to_utf8(record)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
if payload[:event] == "{}"
|
|
354
|
+
log.warn { "Event after formatting was blank, not sending" }
|
|
355
|
+
return ""
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
Oj.dump(payload, mode: :compat)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def format_metric(tag, time, record)
|
|
362
|
+
payload = {
|
|
363
|
+
host: @host ? @host.call(tag, record) : @default_host,
|
|
364
|
+
time: time.to_f.to_s,
|
|
365
|
+
event: "metric"
|
|
366
|
+
}.tap do |p|
|
|
367
|
+
if @time
|
|
368
|
+
time_value = @time.call(tag, record)
|
|
369
|
+
p[:time] = time_value unless time_value.nil?
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
payload[:index] = @index.call(tag, record) if @index
|
|
374
|
+
payload[:source] = @source.call(tag, record) if @source
|
|
375
|
+
payload[:sourcetype] = @sourcetype.call(tag, record) if @sourcetype
|
|
376
|
+
|
|
377
|
+
unless @metrics_from_event
|
|
378
|
+
fields = {
|
|
379
|
+
metric_name: @metric_name.call(tag, record),
|
|
380
|
+
_value: @metric_value.call(tag, record)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if @extra_fields
|
|
384
|
+
fields.update(@extra_fields.transform_values { |field| record[field] })
|
|
385
|
+
fields.delete_if { |_k, v| v.nil? }
|
|
386
|
+
else
|
|
387
|
+
fields.update record
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
fields.delete_if { |_k, v| v.nil? }
|
|
391
|
+
payload[:fields] = convert_to_utf8(fields)
|
|
392
|
+
|
|
393
|
+
return Oj.dump(payload, mode: :compat)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# when metrics_from_event is true, generate one metric event for each key-value in record
|
|
397
|
+
payloads = record.map do |key, value|
|
|
398
|
+
{ fields: { metric_name: key, _value: value } }.merge!(payload)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
payloads.map { |p| Oj.dump(p, mode: :compat) }.join
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def construct_api
|
|
405
|
+
if @full_url.empty?
|
|
406
|
+
URI("#{@protocol}://#{@hec_host}:#{@hec_port}/#{@hec_endpoint.delete_prefix("/")}")
|
|
407
|
+
else
|
|
408
|
+
URI("#{@full_url.delete_suffix("/")}/#{@hec_endpoint.delete_prefix("/")}")
|
|
409
|
+
end
|
|
410
|
+
rescue StandardError
|
|
411
|
+
if @full_url.empty?
|
|
412
|
+
raise Fluent::ConfigError, "hec_host (#{@hec_host}) and/or hec_port (#{@hec_port}) are invalid."
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
raise Fluent::ConfigError, "full_url (#{@full_url}) is invalid."
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def write_to_splunk(chunk)
|
|
419
|
+
post = Net::HTTP::Post.new @api.request_uri
|
|
420
|
+
if @gzip_compression
|
|
421
|
+
post.add_field("Content-Encoding", "gzip")
|
|
422
|
+
gzip_stream = Zlib::GzipWriter.new StringIO.new
|
|
423
|
+
gzip_stream << chunk.read
|
|
424
|
+
post.body = gzip_stream.close.string
|
|
425
|
+
else
|
|
426
|
+
post.body = chunk.read
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
log.debug { "[Sending] Chunk: #{dump_unique_id_hex(chunk.unique_id)}(#{post.body.bytesize}B)." }
|
|
430
|
+
log.trace { "POST #{@api} body=#{post.body}" }
|
|
431
|
+
|
|
432
|
+
begin
|
|
433
|
+
t1 = Time.now
|
|
434
|
+
response = @conn.request @api, post
|
|
435
|
+
t2 = Time.now
|
|
436
|
+
rescue Net::HTTP::Persistent::Error => e
|
|
437
|
+
raise e.cause
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
process_response(response, post.body, t2 - t1)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def process_response(response, request_body, duration)
|
|
444
|
+
log.debug { "[Response] Status: #{response.code} Duration: #{duration}" }
|
|
445
|
+
log.trace { "[Response] POST #{@api}: #{response.inspect}" }
|
|
446
|
+
|
|
447
|
+
@metrics[:status_counter].increment(labels: metric_labels(status: response.code.to_s))
|
|
448
|
+
|
|
449
|
+
raise_err = response.code.to_s.start_with?("5") ||
|
|
450
|
+
(!@consume_chunk_on_4xx_errors && response.code.to_s.start_with?("4"))
|
|
451
|
+
|
|
452
|
+
# raise Exception to utilize Fluentd output plugin retry mechanism
|
|
453
|
+
raise "Server error (#{response.code}) for POST #{@api}, response: #{response.body}" if raise_err
|
|
454
|
+
|
|
455
|
+
# For both success response (2xx) we will consume the chunk.
|
|
456
|
+
return if response.code.to_s.start_with?("2")
|
|
457
|
+
|
|
458
|
+
log.error "#{self.class}: Failed POST to #{@api}, response: #{response.body}"
|
|
459
|
+
log.debug { "#{self.class}: Failed request body: #{request_body}" }
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def convert_to_utf8(input)
|
|
463
|
+
if input.is_a?(Hash)
|
|
464
|
+
record = {}
|
|
465
|
+
input.each do |key, value|
|
|
466
|
+
record[convert_to_utf8(key)] = convert_to_utf8(value)
|
|
467
|
+
end
|
|
468
|
+
return record
|
|
469
|
+
end
|
|
470
|
+
return input.map { |value| convert_to_utf8(value) } if input.is_a?(Array)
|
|
471
|
+
return input unless input.respond_to?(:encode)
|
|
472
|
+
|
|
473
|
+
if @coerce_to_utf8
|
|
474
|
+
input.encode(
|
|
475
|
+
"utf-8",
|
|
476
|
+
invalid: :replace,
|
|
477
|
+
undef: :replace,
|
|
478
|
+
replace: @non_utf8_replacement_string
|
|
479
|
+
)
|
|
480
|
+
else
|
|
481
|
+
begin
|
|
482
|
+
input.encode("utf-8")
|
|
483
|
+
rescue EncodingError
|
|
484
|
+
log.error do
|
|
485
|
+
"Encountered encoding issues potentially due to non " \
|
|
486
|
+
"UTF-8 characters. To allow non-UTF-8 characters and " \
|
|
487
|
+
"replace them with spaces, please set \"coerce_to_utf8\" " \
|
|
488
|
+
"to true."
|
|
489
|
+
end
|
|
490
|
+
raise
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def configure_metrics(conf)
|
|
496
|
+
@metric_labels = {
|
|
497
|
+
type: conf["@type"],
|
|
498
|
+
plugin_id: plugin_id
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
@metrics = {
|
|
502
|
+
record_counter: register_metric(::Prometheus::Client::Counter.new(
|
|
503
|
+
:splunk_output_write_records_count,
|
|
504
|
+
docstring: "The number of log records being sent",
|
|
505
|
+
labels: metric_label_keys
|
|
506
|
+
)),
|
|
507
|
+
bytes_counter: register_metric(::Prometheus::Client::Counter.new(
|
|
508
|
+
:splunk_output_write_bytes_count,
|
|
509
|
+
docstring: "The number of log bytes being sent",
|
|
510
|
+
labels: metric_label_keys
|
|
511
|
+
)),
|
|
512
|
+
status_counter: register_metric(::Prometheus::Client::Counter.new(
|
|
513
|
+
:splunk_output_write_status_count,
|
|
514
|
+
docstring: "The count of sends by response_code",
|
|
515
|
+
labels: metric_label_keys(status: "")
|
|
516
|
+
)),
|
|
517
|
+
write_bytes_histogram: register_metric(::Prometheus::Client::Histogram.new(
|
|
518
|
+
:splunk_output_write_payload_bytes,
|
|
519
|
+
docstring: "The size of the write payload in bytes",
|
|
520
|
+
buckets: [1024, 23_937, 47_875, 95_750, 191_500, 383_000, 766_000,
|
|
521
|
+
1_149_000],
|
|
522
|
+
labels: metric_label_keys
|
|
523
|
+
)),
|
|
524
|
+
write_records_histogram: register_metric(::Prometheus::Client::Histogram.new(
|
|
525
|
+
:splunk_output_write_payload_records,
|
|
526
|
+
docstring: "The number of records written per write",
|
|
527
|
+
buckets: [1, 10, 25, 100, 200, 300, 500, 750, 1000, 1500],
|
|
528
|
+
labels: metric_label_keys
|
|
529
|
+
)),
|
|
530
|
+
write_latency_histogram: register_metric(::Prometheus::Client::Histogram.new(
|
|
531
|
+
:splunk_output_write_latency_seconds,
|
|
532
|
+
docstring: "The latency of writes",
|
|
533
|
+
labels: metric_label_keys
|
|
534
|
+
))
|
|
535
|
+
}
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def metric_labels(other_labels = {})
|
|
539
|
+
@metric_labels.merge other_labels
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def metric_label_keys(other_labels = {})
|
|
543
|
+
(@metric_labels.merge other_labels).keys
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def register_metric(metric)
|
|
547
|
+
if @registry.exist?(metric.name)
|
|
548
|
+
@registry.get(metric.name)
|
|
549
|
+
else
|
|
550
|
+
@registry.register(metric)
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright 2018 Splunk Inc.
|
|
4
|
+
# Modifications Copyright 2025 G. Rahul Nutakki
|
|
5
|
+
#
|
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
# you may not use this file except in compliance with the License.
|
|
8
|
+
# You may obtain a copy of the License at
|
|
9
|
+
#
|
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
#
|
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
# See the License for the specific language governing permissions and
|
|
16
|
+
# limitations under the License.
|
|
17
|
+
|
|
18
|
+
require "fluent/match"
|
|
19
|
+
|
|
20
|
+
module Fluent
|
|
21
|
+
module Plugin
|
|
22
|
+
module SplunkHecRadiant
|
|
23
|
+
# Helper class for pattern-based formatting
|
|
24
|
+
class MatchFormatter
|
|
25
|
+
def initialize(pattern, formatter)
|
|
26
|
+
# based on fluentd/lib/fluent/event_router.rb
|
|
27
|
+
patterns = pattern.split(/\s+/).map do |str|
|
|
28
|
+
Fluent::MatchPattern.create(str)
|
|
29
|
+
end
|
|
30
|
+
@pattern =
|
|
31
|
+
if patterns.length == 1
|
|
32
|
+
patterns[0]
|
|
33
|
+
else
|
|
34
|
+
Fluent::OrMatchPattern.new(patterns)
|
|
35
|
+
end
|
|
36
|
+
@formatter = formatter
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def match?(tag)
|
|
40
|
+
@pattern.match tag
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def format(tag, time, record)
|
|
44
|
+
@formatter.format tag, time, record
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|