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.
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fluent
4
+ module Plugin
5
+ module SumologicRadiant
6
+ VERSION = "0.1.1"
7
+ end
8
+ end
9
+ 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