fluent-plugin-sumologic-radiant 0.1.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.
- checksums.yaml +7 -0
- data/Gemfile +16 -0
- data/LICENSE +201 -0
- data/NOTICE +25 -0
- data/README.md +469 -0
- data/Rakefile +10 -0
- data/fluent-plugin-sumologic-radiant.gemspec +52 -0
- data/lib/fluent/plugin/out_sumologic_radiant.rb +556 -0
- data/lib/fluent/plugin/sumologic_radiant/version.rb +9 -0
- data/lib/fluent-plugin-sumologic-radiant.rb +12 -0
- metadata +226 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fluent/plugin/output"
|
|
4
|
+
require "net/http/persistent"
|
|
5
|
+
require "oj"
|
|
6
|
+
require "zlib"
|
|
7
|
+
require "stringio"
|
|
8
|
+
require "uri"
|
|
9
|
+
require "openssl"
|
|
10
|
+
|
|
11
|
+
module Fluent
|
|
12
|
+
module Plugin
|
|
13
|
+
# Connection handler for Sumo Logic HTTP endpoint
|
|
14
|
+
class SumologicConnection
|
|
15
|
+
attr_reader :http
|
|
16
|
+
|
|
17
|
+
COMPRESS_DEFLATE = "deflate"
|
|
18
|
+
COMPRESS_GZIP = "gzip"
|
|
19
|
+
|
|
20
|
+
def initialize(endpoint, verify_ssl, connect_timeout, send_timeout, receive_timeout, proxy_uri,
|
|
21
|
+
disable_cookies, sumo_client, compress_enabled, compress_encoding, logger,
|
|
22
|
+
ca_file = nil, ca_path = nil, client_cert = nil, client_key = nil)
|
|
23
|
+
@endpoint = URI.parse(endpoint)
|
|
24
|
+
@sumo_client = sumo_client
|
|
25
|
+
@logger = logger
|
|
26
|
+
@compress = compress_enabled
|
|
27
|
+
@compress_encoding = (compress_encoding || COMPRESS_GZIP).downcase
|
|
28
|
+
|
|
29
|
+
unless [COMPRESS_DEFLATE, COMPRESS_GZIP].include?(@compress_encoding)
|
|
30
|
+
raise ArgumentError, "Invalid compression encoding #{@compress_encoding} must be gzip or deflate"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
create_http_client(verify_ssl, connect_timeout, send_timeout, receive_timeout, proxy_uri, disable_cookies,
|
|
34
|
+
ca_file, ca_path, client_cert, client_key)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def publish(raw_data, source_host: nil, source_category: nil, source_name: nil, data_type: nil,
|
|
38
|
+
metric_data_format: nil, collected_fields: nil, dimensions: nil)
|
|
39
|
+
request = Net::HTTP::Post.new(@endpoint.request_uri)
|
|
40
|
+
request_headers(source_host, source_category, source_name, data_type, metric_data_format,
|
|
41
|
+
collected_fields, dimensions).each do |key, value|
|
|
42
|
+
request[key] = value
|
|
43
|
+
end
|
|
44
|
+
request.body = compress(raw_data)
|
|
45
|
+
|
|
46
|
+
response = @http.request(@endpoint, request)
|
|
47
|
+
|
|
48
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
49
|
+
raise "Failed to send data to HTTP Source. #{response.code} - #{response.body}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# response is 20x, check response content
|
|
53
|
+
return if response.body.nil? || response.body.empty?
|
|
54
|
+
|
|
55
|
+
# if we get a non-empty response, check it
|
|
56
|
+
begin
|
|
57
|
+
response_map = Oj.load(response.body)
|
|
58
|
+
rescue Oj::ParseError => e
|
|
59
|
+
@logger.warn "Error decoding receiver response: #{response.body} (#{e.message})"
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# log a warning with the present keys
|
|
64
|
+
response_keys = %w[id code status message errors]
|
|
65
|
+
log_params = response_keys.filter_map do |key|
|
|
66
|
+
"#{key}: #{response_map[key]}" if response_map.key?(key)
|
|
67
|
+
end
|
|
68
|
+
@logger.warn "There was an issue sending data: #{log_params.join(", ")}" if log_params.any?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def request_headers(source_host, source_category, source_name, data_type, metric_data_format,
|
|
74
|
+
collected_fields, dimensions)
|
|
75
|
+
headers = {
|
|
76
|
+
"X-Sumo-Name" => source_name,
|
|
77
|
+
"X-Sumo-Category" => source_category,
|
|
78
|
+
"X-Sumo-Host" => source_host,
|
|
79
|
+
"X-Sumo-Client" => @sumo_client
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
headers["Content-Encoding"] = @compress_encoding if @compress
|
|
83
|
+
|
|
84
|
+
if data_type == "metrics"
|
|
85
|
+
headers["Content-Type"] = case metric_data_format
|
|
86
|
+
when "graphite"
|
|
87
|
+
"application/vnd.sumologic.graphite"
|
|
88
|
+
when "carbon2"
|
|
89
|
+
"application/vnd.sumologic.carbon2"
|
|
90
|
+
when "prometheus"
|
|
91
|
+
"application/vnd.sumologic.prometheus"
|
|
92
|
+
else
|
|
93
|
+
raise ArgumentError,
|
|
94
|
+
"Invalid metric format #{metric_data_format}, " \
|
|
95
|
+
"must be graphite, carbon2, or prometheus"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
headers["X-Sumo-Dimensions"] = dimensions unless dimensions.nil?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
headers["X-Sumo-Fields"] = collected_fields unless collected_fields.nil?
|
|
102
|
+
headers
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def create_http_client(verify_ssl, connect_timeout, send_timeout, receive_timeout, proxy_uri, disable_cookies,
|
|
106
|
+
ca_file, ca_path, client_cert, client_key)
|
|
107
|
+
@http = Net::HTTP::Persistent.new(name: "fluent_sumologic_radiant")
|
|
108
|
+
@http.proxy = URI.parse(proxy_uri) if proxy_uri
|
|
109
|
+
@http.open_timeout = connect_timeout
|
|
110
|
+
@http.read_timeout = receive_timeout
|
|
111
|
+
@http.write_timeout = send_timeout
|
|
112
|
+
@http.idle_timeout = 5
|
|
113
|
+
@http.max_requests = 1000
|
|
114
|
+
|
|
115
|
+
# SSL configuration
|
|
116
|
+
@http.verify_mode = verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
|
117
|
+
@http.min_version = OpenSSL::SSL::TLS1_2_VERSION if verify_ssl
|
|
118
|
+
|
|
119
|
+
# Custom CA certificate configuration
|
|
120
|
+
if ca_file
|
|
121
|
+
@http.ca_file = ca_file
|
|
122
|
+
@logger.info "Using custom CA certificate file: #{ca_file}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
if ca_path
|
|
126
|
+
@http.ca_path = ca_path
|
|
127
|
+
@logger.info "Using custom CA certificate directory: #{ca_path}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Client certificate authentication (mutual TLS)
|
|
131
|
+
if client_cert && client_key
|
|
132
|
+
@http.certificate = OpenSSL::X509::Certificate.new(File.read(client_cert))
|
|
133
|
+
@http.private_key = OpenSSL::PKey::RSA.new(File.read(client_key))
|
|
134
|
+
@logger.info "Client certificate authentication enabled"
|
|
135
|
+
elsif client_cert || client_key
|
|
136
|
+
@logger.warn "Both client_cert and client_key must be specified for mutual TLS. " \
|
|
137
|
+
"Ignoring incomplete configuration."
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Cookie management - net-http-persistent doesn't have built-in cookie support
|
|
141
|
+
# This eliminates the "Unknown key" warnings that httpclient had
|
|
142
|
+
@logger.debug "Cookie handling is managed by Net::HTTP (no cookie warnings)" if disable_cookies
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def compress(content)
|
|
146
|
+
return content unless @compress
|
|
147
|
+
|
|
148
|
+
if @compress_encoding == COMPRESS_GZIP
|
|
149
|
+
gzip(content)
|
|
150
|
+
else
|
|
151
|
+
Zlib::Deflate.deflate(content)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def gzip(content)
|
|
156
|
+
stream = StringIO.new
|
|
157
|
+
stream.set_encoding("ASCII-8BIT")
|
|
158
|
+
gz = Zlib::GzipWriter.new(stream)
|
|
159
|
+
gz.mtime = 1 # Ensure that for same content there is same output
|
|
160
|
+
gz.write(content)
|
|
161
|
+
gz.close
|
|
162
|
+
stream.string
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Main Sumologic output plugin
|
|
167
|
+
class SumologicRadiantOutput < Output
|
|
168
|
+
Fluent::Plugin.register_output("sumologic_radiant", self)
|
|
169
|
+
|
|
170
|
+
helpers :compat_parameters
|
|
171
|
+
|
|
172
|
+
DEFAULT_BUFFER_TYPE = "memory"
|
|
173
|
+
LOGS_DATA_TYPE = "logs"
|
|
174
|
+
METRICS_DATA_TYPE = "metrics"
|
|
175
|
+
DEFAULT_DATA_TYPE = LOGS_DATA_TYPE
|
|
176
|
+
DEFAULT_METRIC_FORMAT_TYPE = "graphite"
|
|
177
|
+
|
|
178
|
+
config_param :data_type, :string, default: DEFAULT_DATA_TYPE
|
|
179
|
+
config_param :metric_data_format, :string, default: DEFAULT_METRIC_FORMAT_TYPE
|
|
180
|
+
config_param :endpoint, :string, secret: true
|
|
181
|
+
config_param :log_format, :string, default: "json"
|
|
182
|
+
config_param :log_key, :string, default: "message"
|
|
183
|
+
config_param :source_category, :string, default: nil
|
|
184
|
+
config_param :source_name, :string, default: nil
|
|
185
|
+
config_param :source_name_key, :string, default: "source_name"
|
|
186
|
+
config_param :source_host, :string, default: nil
|
|
187
|
+
config_param :verify_ssl, :bool, default: true
|
|
188
|
+
config_param :delimiter, :string, default: "."
|
|
189
|
+
config_param :open_timeout, :integer, default: 60
|
|
190
|
+
config_param :receive_timeout, :integer, default: 60
|
|
191
|
+
config_param :send_timeout, :integer, default: 120
|
|
192
|
+
config_param :add_timestamp, :bool, default: true
|
|
193
|
+
config_param :timestamp_key, :string, default: "timestamp"
|
|
194
|
+
config_param :proxy_uri, :string, default: nil
|
|
195
|
+
config_param :disable_cookies, :bool, default: false
|
|
196
|
+
|
|
197
|
+
# SSL/TLS configuration
|
|
198
|
+
desc "Path to CA certificate file for SSL verification"
|
|
199
|
+
config_param :ca_file, :string, default: nil
|
|
200
|
+
desc "Path to CA certificate directory for SSL verification"
|
|
201
|
+
config_param :ca_path, :string, default: nil
|
|
202
|
+
desc "Path to client certificate file for mutual TLS"
|
|
203
|
+
config_param :client_cert, :string, default: nil
|
|
204
|
+
desc "Path to client private key file for mutual TLS"
|
|
205
|
+
config_param :client_key, :string, default: nil
|
|
206
|
+
|
|
207
|
+
config_param :use_internal_retry, :bool, default: false
|
|
208
|
+
config_param :retry_timeout, :time, default: 72 * 3600 # 72h
|
|
209
|
+
config_param :retry_max_times, :integer, default: 0
|
|
210
|
+
config_param :retry_min_interval, :time, default: 1 # 1s
|
|
211
|
+
config_param :retry_max_interval, :time, default: 5 * 60 # 5m
|
|
212
|
+
|
|
213
|
+
config_param :max_request_size, :size, default: 0
|
|
214
|
+
|
|
215
|
+
desc "Fields string (eg 'cluster=payment, service=credit_card') which is going to be added to every log record."
|
|
216
|
+
config_param :custom_fields, :string, default: nil
|
|
217
|
+
desc "Name of sumo client which is sent as X-Sumo-Client header"
|
|
218
|
+
config_param :sumo_client, :string, default: "fluentd-output"
|
|
219
|
+
desc "Compress payload"
|
|
220
|
+
config_param :compress, :bool, default: true
|
|
221
|
+
desc "Encoding method of compression (either gzip or deflate)"
|
|
222
|
+
config_param :compress_encoding, :string, default: SumologicConnection::COMPRESS_GZIP
|
|
223
|
+
desc "Dimensions string (eg 'cluster=payment, service=credit_card') added to every metric record."
|
|
224
|
+
config_param :custom_dimensions, :string, default: nil
|
|
225
|
+
desc "Key to extract metadata from record (e.g., '_sumo_metadata')"
|
|
226
|
+
config_param :sumo_metadata_key, :string, default: nil
|
|
227
|
+
|
|
228
|
+
config_section :buffer do
|
|
229
|
+
config_set_default :@type, DEFAULT_BUFFER_TYPE
|
|
230
|
+
config_set_default :chunk_keys, ["tag"]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def multi_workers_ready?
|
|
234
|
+
true
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def configure(conf)
|
|
238
|
+
compat_parameters_convert(conf, :buffer)
|
|
239
|
+
super
|
|
240
|
+
|
|
241
|
+
begin
|
|
242
|
+
uri = URI.parse(@endpoint)
|
|
243
|
+
unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
244
|
+
raise Fluent::ConfigError, "Invalid SumoLogic endpoint url: #{@endpoint}"
|
|
245
|
+
end
|
|
246
|
+
rescue URI::InvalidURIError
|
|
247
|
+
raise Fluent::ConfigError, "Invalid SumoLogic endpoint url: #{@endpoint}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
unless @data_type.match?(/\A(?:logs|metrics)\z/)
|
|
251
|
+
raise Fluent::ConfigError, "Invalid data_type #{@data_type} must be logs or metrics"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
if @data_type == LOGS_DATA_TYPE && !@log_format.match?(/\A(?:json|text|json_merge|fields)\z/)
|
|
255
|
+
raise Fluent::ConfigError, "Invalid log_format #{@log_format} must be text, json, json_merge or fields"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
if @data_type == METRICS_DATA_TYPE && !@metric_data_format.match?(/\A(?:graphite|carbon2|prometheus)\z/)
|
|
259
|
+
raise Fluent::ConfigError,
|
|
260
|
+
"Invalid metric_data_format #{@metric_data_format} must be graphite, carbon2, or prometheus"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
@custom_fields = validate_key_value_pairs(@custom_fields)
|
|
264
|
+
log.debug "Custom fields: #{@custom_fields}" if @custom_fields
|
|
265
|
+
|
|
266
|
+
@custom_dimensions = validate_key_value_pairs(@custom_dimensions)
|
|
267
|
+
log.debug "Custom dimensions: #{@custom_dimensions}" if @custom_dimensions
|
|
268
|
+
|
|
269
|
+
# Warn if log_format is text or fields but log_key might be problematic
|
|
270
|
+
if @data_type == LOGS_DATA_TYPE && (@log_format == "text" || @log_format == "fields")
|
|
271
|
+
log.warn "log_format is set to '#{@log_format}' which requires log_key='#{@log_key}' " \
|
|
272
|
+
"to exist in your log records. If logs are not being sent, verify this field exists."
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
@sumo_conn = SumologicConnection.new(
|
|
276
|
+
@endpoint,
|
|
277
|
+
@verify_ssl,
|
|
278
|
+
@open_timeout,
|
|
279
|
+
@send_timeout,
|
|
280
|
+
@receive_timeout,
|
|
281
|
+
@proxy_uri,
|
|
282
|
+
@disable_cookies,
|
|
283
|
+
@sumo_client,
|
|
284
|
+
@compress,
|
|
285
|
+
@compress_encoding,
|
|
286
|
+
log,
|
|
287
|
+
@ca_file,
|
|
288
|
+
@ca_path,
|
|
289
|
+
@client_cert,
|
|
290
|
+
@client_key
|
|
291
|
+
)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def shutdown
|
|
295
|
+
super
|
|
296
|
+
@sumo_conn&.http&.shutdown
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Used to merge log record into top level json
|
|
300
|
+
def merge_json(record)
|
|
301
|
+
return record unless record.key?(@log_key)
|
|
302
|
+
|
|
303
|
+
log_value = record[@log_key].strip
|
|
304
|
+
if log_value.start_with?("{") && log_value.end_with?("}")
|
|
305
|
+
begin
|
|
306
|
+
parsed = Oj.load(log_value)
|
|
307
|
+
record = record.merge(parsed)
|
|
308
|
+
record.delete(@log_key)
|
|
309
|
+
rescue Oj::ParseError
|
|
310
|
+
# do nothing, ignore
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
record
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Strip sumo_metadata and dump to json
|
|
317
|
+
def dump_log(log_record)
|
|
318
|
+
log_record.delete("_sumo_metadata")
|
|
319
|
+
begin
|
|
320
|
+
if log_record.key?(@log_key)
|
|
321
|
+
hash = Oj.load(log_record[@log_key])
|
|
322
|
+
log_record[@log_key] = hash
|
|
323
|
+
end
|
|
324
|
+
rescue Oj::ParseError
|
|
325
|
+
# Keep original if parsing fails
|
|
326
|
+
end
|
|
327
|
+
Oj.dump(log_record)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def format(_tag, time, record)
|
|
331
|
+
mstime = if time.respond_to?(:nsec)
|
|
332
|
+
(time.to_i * 1000) + (time.nsec / 1_000_000)
|
|
333
|
+
else
|
|
334
|
+
time.to_i * 1000
|
|
335
|
+
end
|
|
336
|
+
[mstime, record].to_msgpack
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def formatted_to_msgpack_binary?
|
|
340
|
+
true
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def sumo_key(sumo_metadata, chunk)
|
|
344
|
+
source_name = sumo_metadata["source"] || @source_name
|
|
345
|
+
source_name = extract_placeholders(source_name, chunk) unless source_name.nil?
|
|
346
|
+
|
|
347
|
+
source_category = sumo_metadata["category"] || @source_category
|
|
348
|
+
source_category = extract_placeholders(source_category, chunk) unless source_category.nil?
|
|
349
|
+
|
|
350
|
+
source_host = sumo_metadata["host"] || @source_host
|
|
351
|
+
source_host = extract_placeholders(source_host, chunk) unless source_host.nil?
|
|
352
|
+
|
|
353
|
+
fields = sumo_metadata["fields"] || ""
|
|
354
|
+
fields = extract_placeholders(fields, chunk) unless fields.nil?
|
|
355
|
+
|
|
356
|
+
{
|
|
357
|
+
source_name: source_name.to_s,
|
|
358
|
+
source_category: source_category.to_s,
|
|
359
|
+
source_host: source_host.to_s,
|
|
360
|
+
fields: fields.to_s
|
|
361
|
+
}
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Convert timestamp to 13 digit epoch if necessary
|
|
365
|
+
def sumo_timestamp(time)
|
|
366
|
+
time.to_s.length == 13 ? time : time * 1000
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Convert log to string and strip it
|
|
370
|
+
def log_to_str(log_value)
|
|
371
|
+
log_value = Oj.dump(log_value) if log_value.is_a?(Array) || log_value.is_a?(Hash)
|
|
372
|
+
log_value&.strip
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def write(chunk)
|
|
376
|
+
messages_list = {}
|
|
377
|
+
processed_count = 0
|
|
378
|
+
dropped_count = 0
|
|
379
|
+
|
|
380
|
+
log.debug { "Processing chunk #{chunk.dump_unique_id_hex(chunk.unique_id)} with #{chunk.size} bytes" }
|
|
381
|
+
|
|
382
|
+
# Sort messages
|
|
383
|
+
chunk.msgpack_each do |time, record|
|
|
384
|
+
next unless record.is_a?(Hash)
|
|
385
|
+
|
|
386
|
+
processed_count += 1
|
|
387
|
+
|
|
388
|
+
sumo_metadata = if @sumo_metadata_key && record.key?(@sumo_metadata_key)
|
|
389
|
+
record.fetch(@sumo_metadata_key, {})
|
|
390
|
+
else
|
|
391
|
+
record.fetch("_sumo_metadata", { source: record[@source_name_key] })
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
key = sumo_key(sumo_metadata, chunk)
|
|
395
|
+
log_format = sumo_metadata["log_format"] || @log_format
|
|
396
|
+
|
|
397
|
+
# Strip any unwanted newlines
|
|
398
|
+
record[@log_key]&.chomp! if record[@log_key].respond_to?(:chomp!)
|
|
399
|
+
|
|
400
|
+
log = case @data_type
|
|
401
|
+
when "logs"
|
|
402
|
+
format_log(record, log_format, time)
|
|
403
|
+
when "metrics"
|
|
404
|
+
log_to_str(record[@log_key])
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
if log.nil?
|
|
408
|
+
dropped_count += 1
|
|
409
|
+
next
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
messages_list[key] ||= []
|
|
413
|
+
messages_list[key].push(log)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
chunk_id = "##{chunk.dump_unique_id_hex(chunk.unique_id)}"
|
|
417
|
+
|
|
418
|
+
log.debug do
|
|
419
|
+
"Chunk #{chunk_id}: processed #{processed_count} records, " \
|
|
420
|
+
"dropped #{dropped_count} records, sending #{messages_list.values.flatten.size} messages"
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
send_messages(messages_list, chunk_id)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
private
|
|
427
|
+
|
|
428
|
+
def format_log(record, log_format, time)
|
|
429
|
+
case log_format
|
|
430
|
+
when "text"
|
|
431
|
+
unless record.key?(@log_key)
|
|
432
|
+
log.warn "log_format='text' requires log_key='#{@log_key}' but it was not found in record. " \
|
|
433
|
+
"Record keys: #{record.keys.join(", ")}. This log will be dropped. " \
|
|
434
|
+
"Please check your log_key configuration."
|
|
435
|
+
return nil
|
|
436
|
+
end
|
|
437
|
+
log_to_str(record[@log_key])
|
|
438
|
+
when "json_merge"
|
|
439
|
+
record = { @timestamp_key => sumo_timestamp(time) }.merge(record) if @add_timestamp
|
|
440
|
+
dump_log(merge_json(record))
|
|
441
|
+
when "fields"
|
|
442
|
+
record = { @timestamp_key => sumo_timestamp(time) }.merge(record) if @add_timestamp
|
|
443
|
+
dump_log(record)
|
|
444
|
+
else # json
|
|
445
|
+
record = { @timestamp_key => sumo_timestamp(time) }.merge(record) if @add_timestamp
|
|
446
|
+
dump_log(record)
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def send_messages(messages_list, chunk_id)
|
|
451
|
+
messages_list.each do |key, messages|
|
|
452
|
+
source_name = key[:source_name]
|
|
453
|
+
source_category = key[:source_category]
|
|
454
|
+
source_host = key[:source_host]
|
|
455
|
+
fields = key[:fields]
|
|
456
|
+
|
|
457
|
+
# Merge custom and record fields
|
|
458
|
+
fields = if fields.nil? || fields.strip.empty?
|
|
459
|
+
@custom_fields
|
|
460
|
+
else
|
|
461
|
+
[fields, @custom_fields].compact.join(",")
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
messages_to_send = split_messages_by_size(messages)
|
|
465
|
+
|
|
466
|
+
messages_to_send.each_with_index do |message_batch, i|
|
|
467
|
+
send_batch_with_retry(message_batch, source_name, source_category, source_host,
|
|
468
|
+
fields, chunk_id, i)
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def split_messages_by_size(messages)
|
|
474
|
+
return [messages] if @max_request_size <= 0
|
|
475
|
+
|
|
476
|
+
messages_to_send = []
|
|
477
|
+
current_message = []
|
|
478
|
+
current_length = 0
|
|
479
|
+
|
|
480
|
+
messages.each do |message|
|
|
481
|
+
current_message.push(message)
|
|
482
|
+
current_length += message.length
|
|
483
|
+
|
|
484
|
+
if current_length > @max_request_size
|
|
485
|
+
messages_to_send.push(current_message)
|
|
486
|
+
current_message = []
|
|
487
|
+
current_length = 0
|
|
488
|
+
end
|
|
489
|
+
current_length += 1 # newline character
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
messages_to_send.push(current_message) if current_message.any?
|
|
493
|
+
messages_to_send
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def send_batch_with_retry(message_batch, source_name, source_category, source_host, fields, chunk_id, batch_index)
|
|
497
|
+
retries = 0
|
|
498
|
+
start_time = Time.now
|
|
499
|
+
sleep_time = @retry_min_interval
|
|
500
|
+
|
|
501
|
+
loop do
|
|
502
|
+
common_log_part = "#{@data_type} records with source category '#{source_category}', " \
|
|
503
|
+
"source host '#{source_host}', source name '#{source_name}', " \
|
|
504
|
+
"chunk #{chunk_id}, try #{retries}, batch #{batch_index}"
|
|
505
|
+
|
|
506
|
+
begin
|
|
507
|
+
log.debug { "Sending #{message_batch.count}; #{common_log_part}" }
|
|
508
|
+
|
|
509
|
+
@sumo_conn.publish(
|
|
510
|
+
message_batch.join("\n"),
|
|
511
|
+
source_host: source_host,
|
|
512
|
+
source_category: source_category,
|
|
513
|
+
source_name: source_name,
|
|
514
|
+
data_type: @data_type,
|
|
515
|
+
metric_data_format: @metric_data_format,
|
|
516
|
+
collected_fields: fields,
|
|
517
|
+
dimensions: @custom_dimensions
|
|
518
|
+
)
|
|
519
|
+
break
|
|
520
|
+
rescue StandardError => e
|
|
521
|
+
raise e unless @use_internal_retry
|
|
522
|
+
|
|
523
|
+
retries += 1
|
|
524
|
+
log.warn "error while sending request to sumo: #{e}; #{common_log_part}"
|
|
525
|
+
log.warn_backtrace e.backtrace
|
|
526
|
+
|
|
527
|
+
# Drop data if we exceeded retry limits
|
|
528
|
+
if (retries >= @retry_max_times && @retry_max_times.positive?) ||
|
|
529
|
+
(Time.now > start_time + @retry_timeout && @retry_timeout.positive?)
|
|
530
|
+
log.warn "dropping records; #{common_log_part}"
|
|
531
|
+
break
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
log.info "going to retry to send data at #{Time.now + sleep_time}; #{common_log_part}"
|
|
535
|
+
sleep sleep_time
|
|
536
|
+
|
|
537
|
+
sleep_time *= 2
|
|
538
|
+
sleep_time = @retry_max_interval if sleep_time > @retry_max_interval
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def validate_key_value_pairs(fields)
|
|
544
|
+
return nil if fields.nil?
|
|
545
|
+
|
|
546
|
+
validated = fields.split(",").select do |field|
|
|
547
|
+
field.split("=").length == 2
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
return nil if validated.empty?
|
|
551
|
+
|
|
552
|
+
validated.join(",")
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "fluent/plugin/sumologic_radiant/version"
|
|
4
|
+
require_relative "fluent/plugin/out_sumologic_radiant"
|
|
5
|
+
|
|
6
|
+
module Fluent
|
|
7
|
+
module Plugin
|
|
8
|
+
module SumologicRadiant
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|