microsoft-sentinel-logstash-output-plugin 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,58 @@
1
+ # encoding: utf-8
2
+ require "logstash/sentinel/logstashLoganalyticsConfiguration"
3
+
4
+ module LogStash
5
+ module Outputs
6
+ class MicrosoftSentinelOutputInternal
7
+ class EventsHandler
8
+
9
+ def initialize(logstashLogAnalyticsConfiguration)
10
+ @logstashLogAnalyticsConfiguration = logstashLogAnalyticsConfiguration
11
+ @logger = logstashLogAnalyticsConfiguration.logger
12
+ @key_names = logstashLogAnalyticsConfiguration.key_names
13
+ @columns_to_modify = {"@timestamp" => "ls_timestamp", "@version" => "ls_version"}
14
+ end
15
+
16
+ def handle_events(events)
17
+ raise "Method handle_events not implemented"
18
+ end
19
+
20
+ def close
21
+ raise "Method close not implemented"
22
+ end
23
+
24
+ # In case that the user has defined key_names meaning that he would like to a subset of the data,
25
+ # we would like to insert only those keys.
26
+ # If no keys were defined we will send all the data
27
+ def create_event_document(event)
28
+ document = {}
29
+ event_hash = event.to_hash
30
+
31
+ @columns_to_modify.each {|original_key, new_key|
32
+ if event_hash.has_key?(original_key)
33
+ event_hash[new_key] = event_hash[original_key]
34
+ event_hash.delete(original_key)
35
+ end
36
+ }
37
+
38
+ if @key_names.length > 0
39
+ # Get the intersection of key_names and keys of event_hash
40
+ keys_intersection = @key_names & event_hash.keys
41
+ keys_intersection.each do |key|
42
+ document[key] = event_hash[key]
43
+ end
44
+ if document.keys.length < 1
45
+ @logger.warn("No keys found, message is dropped. Plugin keys: #{@key_names}, Event keys: #{event_hash}. The event message do not match event expected structre. Please edit key_names section in output plugin and try again.")
46
+ end
47
+ else
48
+ document = event_hash
49
+ end
50
+
51
+ return document
52
+ end
53
+ # def create_event_document
54
+
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,93 @@
1
+ # encoding: utf-8
2
+ require "logstash/sentinel/logstashLoganalyticsConfiguration"
3
+ require 'rest-client'
4
+ require 'json'
5
+ require 'openssl'
6
+ require 'base64'
7
+ require 'time'
8
+
9
+ module LogStash; module Outputs; class MicrosoftSentinelOutputInternal
10
+ class LogAnalyticsAadTokenProvider
11
+ def initialize (logstashLoganalyticsConfiguration)
12
+ set_proxy(logstashLoganalyticsConfiguration.proxy)
13
+ scope = CGI.escape("https://monitor.azure.com//.default")
14
+ @token_request_body = sprintf("client_id=%s&scope=%s&client_secret=%s&grant_type=client_credentials", logstashLoganalyticsConfiguration.client_app_Id, scope, logstashLoganalyticsConfiguration.client_app_secret)
15
+ @token_request_uri = sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", logstashLoganalyticsConfiguration.tenant_id)
16
+ @token_state = {
17
+ :access_token => nil,
18
+ :expiry_time => nil,
19
+ :token_details_mutex => Mutex.new,
20
+ }
21
+ @logger = logstashLoganalyticsConfiguration.logger
22
+ end # def initialize
23
+
24
+ # Public methods
25
+ public
26
+
27
+ def get_aad_token_bearer()
28
+ @token_state[:token_details_mutex].synchronize do
29
+ if is_saved_token_need_refresh()
30
+ refresh_saved_token()
31
+ end
32
+ return @token_state[:access_token]
33
+ end
34
+ end # def get_aad_token_bearer
35
+
36
+ # Private methods
37
+ private
38
+
39
+ def is_saved_token_need_refresh()
40
+ return @token_state[:access_token].nil? || @token_state[:expiry_time].nil? || @token_state[:expiry_time] <= Time.now
41
+ end # def is_saved_token_need_refresh
42
+
43
+ def refresh_saved_token()
44
+ @logger.info("aad token expired - refreshing token.")
45
+
46
+ token_response = post_token_request()
47
+ @token_state[:access_token] = token_response["access_token"]
48
+ @token_state[:expiry_time] = get_token_expiry_time(token_response["expires_in"])
49
+ end # def refresh_saved_token
50
+
51
+ def get_token_expiry_time (expires_in_seconds)
52
+ if (expires_in_seconds.nil? || expires_in_seconds <= 0)
53
+ return Time.now + (60 * 60 * 24) # Refresh anyway in 24 hours
54
+ else
55
+ return Time.now + expires_in_seconds - 1; # Decrease by 1 second to be on the safe side
56
+ end
57
+ end # def get_token_expiry_time
58
+
59
+ # Post the given json to Azure Loganalytics
60
+ def post_token_request()
61
+ # Create REST request header
62
+ header = get_header()
63
+ begin
64
+ # Post REST request
65
+ response = RestClient.post(@token_request_uri, @token_request_body, header)
66
+ if (response.code == 200 || response.code == 201)
67
+ return JSON.parse(response.body)
68
+ else
69
+ @logger.trace("Rest client response from ADD API ['#{response}']")
70
+ raise ("Failed to get AAD token: http code " + response.code.to_s)
71
+ end
72
+ rescue RestClient::ExceptionWithResponse => ewr
73
+ @logger.trace("Rest client response from ADD API ['#{ewr.response}']")
74
+ raise ("Failed to get AAD token: http code " + ewr.response.code.to_s)
75
+ end
76
+ end # def post_token_request
77
+
78
+ # Create a header
79
+ def get_header()
80
+ return {
81
+ 'Content-Type' => 'application/x-www-form-urlencoded',
82
+ }
83
+ end # def get_header
84
+
85
+ # Setting proxy for the REST client.
86
+ # This option is not used in the output plugin and will be used
87
+ #
88
+ def set_proxy(proxy='')
89
+ RestClient.proxy = proxy.empty? ? ENV['http_proxy'] : proxy
90
+ end # def set_proxy
91
+
92
+ end # end of class
93
+ end ;end ;end
@@ -0,0 +1,90 @@
1
+ # encoding: utf-8
2
+ require "logstash/sentinel/version"
3
+ require 'rest-client'
4
+ require 'json'
5
+ require 'openssl'
6
+ require 'base64'
7
+ require 'time'
8
+ require 'rbconfig'
9
+
10
+ module LogStash; module Outputs; class MicrosoftSentinelOutputInternal
11
+ class LogAnalyticsClient
12
+ require "logstash/sentinel/logstashLoganalyticsConfiguration"
13
+ require "logstash/sentinel/logAnalyticsAadTokenProvider"
14
+
15
+
16
+ def initialize (logstashLoganalyticsConfiguration)
17
+ @logstashLoganalyticsConfiguration = logstashLoganalyticsConfiguration
18
+ @logger = @logstashLoganalyticsConfiguration.logger
19
+
20
+ set_proxy(@logstashLoganalyticsConfiguration.proxy)
21
+ la_api_version = "2021-11-01-preview"
22
+ @uri = sprintf("%s/dataCollectionRules/%s/streams/%s?api-version=%s",@logstashLoganalyticsConfiguration.data_collection_endpoint, @logstashLoganalyticsConfiguration.dcr_immutable_id, logstashLoganalyticsConfiguration.dcr_stream_name, la_api_version)
23
+ @aadTokenProvider=LogAnalyticsAadTokenProvider::new(logstashLoganalyticsConfiguration)
24
+ @userAgent = getUserAgent()
25
+ end # def initialize
26
+
27
+ # Post the given json to Azure Loganalytics
28
+ def post_data(body)
29
+ raise ConfigError, 'no json_records' if body.empty?
30
+
31
+ # Create REST request header
32
+ header = get_header()
33
+
34
+ # Post REST request
35
+ response = RestClient.post(@uri, body, header)
36
+ return response
37
+ end # def post_data
38
+
39
+ # Static function to return if the response is OK or else
40
+ def self.is_successfully_posted(response)
41
+ return (response.code >= 200 && response.code < 300 ) ? true : false
42
+ end # def self.is_successfully_posted
43
+
44
+ private
45
+
46
+ # Create a header for the given length
47
+ def get_header()
48
+ # Getting an authorization token bearer (if the token is expired, the method will post a request to get a new authorization token)
49
+ token_bearer = @aadTokenProvider.get_aad_token_bearer()
50
+
51
+ headers = {
52
+ 'Content-Type' => 'application/json',
53
+ 'Authorization' => sprintf("Bearer %s", token_bearer),
54
+ 'User-Agent' => @userAgent
55
+ }
56
+
57
+ if @logstashLoganalyticsConfiguration.compress_data
58
+ headers = headers.merge({
59
+ 'Content-Encoding' => 'gzip'
60
+ })
61
+ end
62
+
63
+ return headers
64
+ end # def get_header
65
+
66
+ # Setting proxy for the REST client.
67
+ # This option is not used in the output plugin and will be used
68
+ def set_proxy(proxy='')
69
+ RestClient.proxy = proxy.empty? ? ENV['http_proxy'] : proxy
70
+ end # def set_proxy
71
+
72
+ def ruby_agent_version()
73
+ case RUBY_ENGINE
74
+ when 'jruby'
75
+ "jruby/#{JRUBY_VERSION} (#{RUBY_VERSION}p#{RUBY_PATCHLEVEL})"
76
+ else
77
+ "#{RUBY_ENGINE}/#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}"
78
+ end
79
+ end
80
+
81
+ def architecture()
82
+ "#{RbConfig::CONFIG['host_os']} #{RbConfig::CONFIG['host_cpu']}"
83
+ end
84
+
85
+ def getUserAgent()
86
+ "SentinelLogstashPlugin|#{LogStash::Outputs::MicrosoftSentinelOutputInternal::VERSION}|#{architecture}|#{ruby_agent_version}"
87
+ end #getUserAgent
88
+
89
+ end # end of class
90
+ end ;end ;end
@@ -0,0 +1,157 @@
1
+ # encoding: utf-8
2
+
3
+ require "logstash/sentinel/logstashLoganalyticsConfiguration"
4
+ require "logstash/sentinel/customSizeBasedBuffer"
5
+ require "logstash/sentinel/logStashEventsBatcher"
6
+
7
+ # LogStashAutoResizeBuffer class setting a resizable buffer which is flushed periodically
8
+ # The buffer resize itself according to Azure Loganalytics and configuration limitations
9
+ # This buffer will increase and decrease size according to the amount of messages inserted.
10
+ # If the buffer reached the max amount of messages the amount will be increased until the limit
11
+ module LogStash; module Outputs; class MicrosoftSentinelOutputInternal
12
+ class LogStashAutoResizeBuffer < LogStashEventsBatcher
13
+ include CustomSizeBasedBuffer
14
+
15
+ def initialize(logstashLoganalyticsConfiguration)
16
+ buffer_initialize(
17
+ :max_items => logstashLoganalyticsConfiguration.max_items,
18
+ :max_interval => logstashLoganalyticsConfiguration.plugin_flush_interval,
19
+ :logger => logstashLoganalyticsConfiguration.logger,
20
+ #todo: There is a small discrepancy between the total size of the documents and the message body
21
+ :flush_each => logstashLoganalyticsConfiguration.MAX_SIZE_BYTES - 2000
22
+ )
23
+ super
24
+ end # initialize
25
+
26
+ # Public methods
27
+ public
28
+
29
+ # Adding an event document into the buffer
30
+ def batch_event(event_document)
31
+ buffer_receive(event_document)
32
+ end # def batch_event
33
+
34
+ # Flushing all buffer content to Azure Loganalytics.
35
+ # Called from Stud::Buffer#buffer_flush when there are events to flush
36
+ def flush (documents, close=false)
37
+ # Skip in case there are no candidate documents to deliver
38
+ if documents.length < 1
39
+ @logger.warn("No documents in batch for log type #{@logstashLoganalyticsConfiguration.dcr_stream_name}. Skipping")
40
+ return
41
+ end
42
+
43
+ # We send Json in the REST request
44
+ documents_json = documents.to_json
45
+ documents_byte_size = documents_json.bytesize
46
+ if (documents_byte_size <= @logstashLoganalyticsConfiguration.MAX_SIZE_BYTES)
47
+ # Setting resizing to true will cause changing the max size
48
+ if @logstashLoganalyticsConfiguration.amount_resizing == true
49
+ # Resizing the amount of messages according to size of message received and amount of messages
50
+ change_message_limit_size(documents.length, documents_byte_size)
51
+ end
52
+ send_message_to_loganalytics(documents_json, documents.length)
53
+ else
54
+ warn_documents_size_over_limitation(documents, documents_byte_size)
55
+ split_documents_lists = split_document_list_to_sublists_by_max_size(documents, documents_byte_size)
56
+ @logger.trace("Number of documents: #{documents.length}, Number of split lists to send separately: #{split_documents_lists.length}");
57
+ send_split_documents_list_to_loganalytics(split_documents_lists)
58
+ end
59
+ end # def flush
60
+
61
+ def close
62
+ buffer_flush(:final => true)
63
+ end
64
+
65
+ # Private methods
66
+ private
67
+
68
+ def warn_documents_size_over_limitation(documents, documents_byte_size)
69
+ average_document_size = documents_byte_size / documents.length
70
+ recommended_max_items = (@buffer_config[:flush_each] / average_document_size).floor
71
+
72
+ if @logstashLoganalyticsConfiguration.amount_resizing == true
73
+ change_buffer_size(recommended_max_items)
74
+ else
75
+ @logger.info("Warning: The size of the batch to post (#{documents_byte_size} bytes) is higher than the maximum allowed to post (#{@logstashLoganalyticsConfiguration.MAX_SIZE_BYTES}).")
76
+ end
77
+
78
+ end
79
+ # This will convert our documents list into a list of sublists. Each sublist size will be lower than the max allowed size, and will be posted separately, in order to avoid the endpoint failing the request due to size limitation..
80
+ def split_document_list_to_sublists_by_max_size(all_documents, documents_byte_size)
81
+ number_of_sublists = (documents_byte_size.to_f / @logstashLoganalyticsConfiguration.MAX_SIZE_BYTES.to_f).ceil # If max size is 1MB and actual size is 2.5MB - this will return 3.
82
+ split_documents_lists = all_documents.each_slice((all_documents.size/number_of_sublists.to_f).round).to_a
83
+ final_documents_lists = Array.new
84
+
85
+ for documents_sublist in split_documents_lists do
86
+ documents_sublist_byte_size = documents_sublist.to_json.bytesize
87
+
88
+ if (documents_sublist_byte_size >= @logstashLoganalyticsConfiguration.MAX_SIZE_BYTES)
89
+ if (documents_sublist.length > 1)
90
+ final_documents_lists.concat(split_document_list_to_sublists_by_max_size(documents_sublist, documents_sublist_byte_size))
91
+ else
92
+ @logger.error("Received document above the max allowed size - dropping the document [document size: #{current_document_size}, max allowed size: #{@logstashLoganalyticsConfiguration.MAX_SIZE_BYTES}")
93
+ end
94
+ else
95
+ final_documents_lists.push(documents_sublist)
96
+ end
97
+ end
98
+
99
+ return final_documents_lists
100
+ end
101
+
102
+ def send_split_documents_list_to_loganalytics(split_documents_lists)
103
+ for documents in split_documents_lists do
104
+ send_message_to_loganalytics(documents.to_json, documents.length)
105
+ end
106
+ end
107
+
108
+ # We would like to change the amount of messages in the buffer (change_max_size)
109
+ # We change the amount according to the Azure Loganalytics limitation and the amount of messages inserted to the buffer
110
+ # in one sending window.
111
+ # Meaning that if we reached the max amount we would like to increase it.
112
+ # Else we would like to decrease it(to reduce latency for messages)
113
+ def change_message_limit_size(amount_of_documents, documents_byte_size)
114
+ current_buffer_size = @buffer_config[:max_items]
115
+ new_buffer_size = current_buffer_size
116
+ average_document_size = documents_byte_size / amount_of_documents
117
+
118
+ # If window is full we need to increase it
119
+ # "amount_of_documents" can be greater since buffer is not synchronized meaning
120
+ # that flush can occur after limit was reached.
121
+ if amount_of_documents >= current_buffer_size
122
+ # if doubling the size wouldn't exceed the API limit
123
+ if ((2 * current_buffer_size) * average_document_size) < @buffer_config[:flush_each]
124
+ new_buffer_size = 2 * current_buffer_size
125
+ # If doubling the size will exceed the API limit, change it to be as close as possible to the API limit (minus 4kb just to be safe) - but don't cause it to decrease
126
+ else
127
+ average_documents_in_4kb = (average_document_size / 4000).ceil
128
+ potential_new_buffer_size = (@buffer_config[:flush_each] / average_document_size) -average_documents_in_4kb
129
+ if potential_new_buffer_size > new_buffer_size
130
+ new_buffer_size = potential_new_buffer_size
131
+ end
132
+ end
133
+
134
+ # We would like to decrease the window but not more then the MIN_MESSAGE_AMOUNT
135
+ # We are trying to decrease it slowly to be able to send as much messages as we can in one window
136
+ elsif amount_of_documents < current_buffer_size and current_buffer_size != [(current_buffer_size - @logstashLoganalyticsConfiguration.decrease_factor) ,@logstashLoganalyticsConfiguration.MIN_MESSAGE_AMOUNT].max
137
+ new_buffer_size = [(current_buffer_size - @logstashLoganalyticsConfiguration.decrease_factor) ,@logstashLoganalyticsConfiguration.MIN_MESSAGE_AMOUNT].max
138
+ end
139
+
140
+ change_buffer_size(new_buffer_size)
141
+ end # def change_message_limit_size
142
+
143
+ # Receiving new_size as the new max buffer size.
144
+ # Changing both the buffer, the configuration and logging as necessary
145
+ def change_buffer_size(new_size)
146
+ # Change buffer size only if it's needed(new size)
147
+ if @buffer_config[:max_items] != new_size
148
+ old_buffer_size = @buffer_config[:max_items]
149
+ @buffer_config[:max_items] = new_size
150
+ @logger.trace("Changing buffer size.[configuration='#{old_buffer_size}' , new_size='#{new_size}']")
151
+ else
152
+ @logger.trace("Buffer size wasn't changed.[configuration='#{old_buffer_size}' , new_size='#{new_size}']")
153
+ end
154
+ end # def change_buffer_size
155
+
156
+ end # LogStashAutoResizeBuffer
157
+ end ;end ;end
@@ -0,0 +1,144 @@
1
+ # encoding: utf-8
2
+
3
+ require "logstash/sentinel/logstashLoganalyticsConfiguration"
4
+ require "logstash/sentinel/customSizeBasedBuffer"
5
+ require "logstash/sentinel/logStashEventsBatcher"
6
+ require 'zlib'
7
+
8
+ module LogStash; module Outputs; class MicrosoftSentinelOutputInternal
9
+ class LogStashCompressedStream < LogStashEventsBatcher
10
+ include CustomSizeBasedBuffer
11
+ #This is a basic memory based buffer with size and time bounds
12
+ #Events are compressed when entering the buffer.
13
+ def initialize(logstashLoganalyticsConfiguration)
14
+ @compression_buffer = StringIO.new
15
+ @deflater = Zlib::Deflate.new
16
+
17
+ @compression_stream_state = {
18
+ :events_in_compression_buffer => 0,
19
+ :original_events_size => 0,
20
+ :last_flush => Time.now.to_i,
21
+ # guarding the flush
22
+ :flush_mutex => Mutex.new,
23
+ # guarding the stream
24
+ :insert_mutex => Mutex.new
25
+ }
26
+
27
+ buffer_initialize(
28
+ :max_items => logstashLoganalyticsConfiguration.max_items,
29
+ :max_interval => logstashLoganalyticsConfiguration.plugin_flush_interval,
30
+ :logger => logstashLoganalyticsConfiguration.logger,
31
+ :flush_each => logstashLoganalyticsConfiguration.MAX_SIZE_BYTES - 1000
32
+ )
33
+ super
34
+ end # initialize
35
+
36
+ public
37
+ # Adding an event document into the buffer
38
+ def batch_event(event_document)
39
+ buffer_receive(event_document)
40
+ end # def batch_event
41
+
42
+ def flush (documents, close=false)
43
+ @compression_stream_state[:insert_mutex].synchronize do
44
+ documents.each do |document|
45
+ add_event_to_compression_buffer(document)
46
+ end
47
+ end
48
+ end
49
+
50
+ def close
51
+ buffer_flush(:final => true)
52
+ flush_compression_buffer(:final => true)
53
+ end
54
+
55
+ protected
56
+ def get_time_since_last_flush
57
+ Time.now.to_i - @compression_stream_state[:last_flush]
58
+ end
59
+
60
+ # This override is to pickup on forced flushes, for example when timer is firing
61
+ def buffer_flush(options={})
62
+ super
63
+ if options[:force]
64
+ @compression_stream_state[:insert_mutex].synchronize do
65
+ flush_compression_buffer(:force => true)
66
+ end
67
+ end
68
+ end
69
+
70
+ # Adding an event document into the compressed stream
71
+ private
72
+ def add_event_to_compression_buffer(event_document)
73
+ event_json = event_document.to_json
74
+
75
+ # Ensure that adding the current event to the stream will not exceed the maximum size allowed.
76
+ # If so, first flush and clear the current stream and then add the current record to the new stream instance.
77
+ if event_json.bytesize + @deflater.total_out > @buffer_config[:flush_each]
78
+ flush_compression_buffer
79
+ end
80
+
81
+ buffer_empty? ? write_string_to_compression_buffer("[") :
82
+ write_string_to_compression_buffer(",")
83
+
84
+ write_string_to_compression_buffer(event_json)
85
+ @compression_stream_state[:events_in_compression_buffer] += 1
86
+ @compression_stream_state[:original_events_size] += event_json.bytesize
87
+ end
88
+
89
+ def write_string_to_compression_buffer(string_to_compress)
90
+ @compression_buffer << @deflater.deflate(string_to_compress, Zlib::SYNC_FLUSH)
91
+ end
92
+
93
+ def buffer_empty?
94
+ @compression_stream_state[:events_in_compression_buffer] == 0
95
+ end
96
+
97
+ def flush_compression_buffer(options={})
98
+ # logstash service is shutting down, flush all pending logs
99
+ final = options[:final]
100
+ # Force is passed when the timer fires - ensure the stream will flush every x seconds
101
+ force = options[:force]
102
+
103
+ # there might be more than one thread trying to flush concurrently
104
+ if final
105
+ # final flush will wait for lock, so we are sure to flush out all buffered events
106
+ @compression_stream_state[:flush_mutex].lock
107
+ elsif ! @compression_stream_state[:flush_mutex].try_lock # failed to get lock, another flush already in progress
108
+ return
109
+ end
110
+
111
+ begin
112
+ time_since_last_flush = get_time_since_last_flush
113
+
114
+ if buffer_empty? ||
115
+ (get_time_since_last_flush < @buffer_config[:max_interval] && force && (!final))
116
+ @logger.trace("flushing aborted. buffer_empty? #{buffer_empty?} time_since_last_flush #{time_since_last_flush} force #{force} final #{final}")
117
+ return
118
+ end
119
+
120
+ write_string_to_compression_buffer("]")
121
+ @compression_buffer.flush
122
+ outgoing_data = @compression_buffer.string
123
+
124
+ number_outgoing_events = @compression_stream_state[:events_in_compression_buffer]
125
+ @logger.trace("about to send [#{@compression_stream_state[:events_in_compression_buffer]}] events. Compressed data byte size [#{outgoing_data.bytesize}] Original data byte size [#{@compression_stream_state[:original_events_size]}].")
126
+
127
+ reset_compression_stream
128
+
129
+ send_message_to_loganalytics(outgoing_data, number_outgoing_events)
130
+ ensure
131
+ @compression_stream_state[:flush_mutex].unlock
132
+ end
133
+ end
134
+
135
+ def reset_compression_stream
136
+ @deflater.reset
137
+ @compression_buffer.reopen("")
138
+ @compression_stream_state[:events_in_compression_buffer] = 0
139
+ @compression_stream_state[:original_events_size] = 0
140
+ @compression_stream_state[:last_flush] = Time.now.to_i
141
+ end
142
+
143
+ end
144
+ end;end;end;
@@ -0,0 +1,116 @@
1
+ # encoding: utf-8
2
+
3
+ require "logstash/sentinel/logAnalyticsClient"
4
+ require "logstash/sentinel/logstashLoganalyticsConfiguration"
5
+
6
+ # LogStashAutoResizeBuffer class setting a resizable buffer which is flushed periodically
7
+ # The buffer resize itself according to Azure Loganalytics and configuration limitations
8
+ module LogStash; module Outputs; class MicrosoftSentinelOutputInternal
9
+ class LogStashEventsBatcher
10
+
11
+ def initialize(logstashLoganalyticsConfiguration)
12
+ @logstashLoganalyticsConfiguration = logstashLoganalyticsConfiguration
13
+ @logger = @logstashLoganalyticsConfiguration.logger
14
+ @client = LogAnalyticsClient::new(logstashLoganalyticsConfiguration)
15
+ end # initialize
16
+
17
+ public
18
+ def batch_event_document(event_document)
19
+ # todo: ensure the json serialization only occurs once.
20
+ current_document_size = event_document.to_json.bytesize
21
+ if (current_document_size >= @logstashLoganalyticsConfiguration.MAX_SIZE_BYTES - 1000)
22
+ @logger.error("Received document above the max allowed size - dropping the document [document size: #{current_document_size}, max allowed size: #{@buffer_config[:flush_each]}")
23
+ else
24
+ batch_event(event_document)
25
+ end
26
+ end # def add_event_document
27
+
28
+ protected
29
+ def close
30
+ raise "Method close not implemented"
31
+ end
32
+
33
+ def batch_event(event_document)
34
+ raise "Method batch_event not implemented"
35
+ end
36
+
37
+ def send_message_to_loganalytics(call_payload, amount_of_documents)
38
+
39
+ retransmission_timeout = Time.now.to_i + @logstashLoganalyticsConfiguration.retransmission_time
40
+ api_name = "Logs Ingestion API"
41
+ is_retry = false
42
+ force_retry = false
43
+
44
+ while Time.now.to_i < retransmission_timeout || force_retry
45
+ seconds_to_sleep = @logstashLoganalyticsConfiguration.RETRANSMISSION_DELAY
46
+ force_retry = false
47
+ # Retry logic:
48
+ # 400 bad request or general exceptions are dropped
49
+ # 429 (too many requests) are retried forever
50
+ # All other http errors are retried for total every of @logstashLoganalyticsConfiguration.RETRANSMISSION_DELAY until @logstashLoganalyticsConfiguration.retransmission_time seconds passed
51
+ begin
52
+ @logger.debug(transmission_verb(is_retry) + " log batch (amount of documents: #{amount_of_documents}) to DCR stream #{@logstashLoganalyticsConfiguration.dcr_stream_name} to #{api_name}.")
53
+ response = @client.post_data(call_payload)
54
+
55
+ if LogAnalyticsClient.is_successfully_posted(response)
56
+ @logger.info("Successfully posted #{amount_of_documents} logs into log analytics DCR stream [#{@logstashLoganalyticsConfiguration.dcr_stream_name}].")
57
+ return
58
+ else
59
+ @logger.error("#{api_name} request failed. Error code: #{response.code} #{try_get_info_from_error_response(response)}")
60
+ @logger.trace("Rest client response ['#{response}']")
61
+ end
62
+
63
+ rescue RestClient::ExceptionWithResponse => ewr
64
+ response = ewr.response
65
+ @logger.error("Exception when posting data to #{api_name}. [Exception: '#{ewr}'] #{try_get_info_from_error_response(ewr.response)} [amount of documents=#{amount_of_documents}]'")
66
+ @logger.trace("Exception in posting data to #{api_name}. Rest client response ['#{ewr.response}']. [amount_of_documents=#{amount_of_documents} request payload=#{call_payload}]")
67
+
68
+ if ewr.http_code.to_f == 400
69
+ @logger.info("Not trying to resend since exception http code is #{ewr.http_code}")
70
+ return
71
+ elsif ewr.http_code.to_f == 429
72
+ # thrutteling detected, backoff before resending
73
+ parsed_retry_after = response.headers.include?(:retry_after) ? response.headers[:retry_after].to_i : 0
74
+ seconds_to_sleep = parsed_retry_after > 0 ? parsed_retry_after : 30
75
+
76
+ #force another retry even if the next iteration of the loop will be after the retransmission_timeout
77
+ force_retry = true
78
+ end
79
+ rescue Exception => ex
80
+ @logger.error("Exception in posting data to #{api_name}. [Exception: '#{ex}, amount of documents=#{amount_of_documents}]'")
81
+ @logger.trace("Exception in posting data to #{api_name}.[amount_of_documents=#{amount_of_documents} request payload=#{call_payload}]")
82
+ end
83
+ is_retry = true
84
+ @logger.info("Retrying transmission to #{api_name} in #{seconds_to_sleep} seconds.")
85
+
86
+ sleep seconds_to_sleep
87
+ end
88
+
89
+ @logger.error("Could not resend #{amount_of_documents} documents, message is dropped after retransmission_time exceeded.")
90
+ @logger.trace("Documents (#{amount_of_documents}) dropped. [call_payload=#{call_payload}]")
91
+ end # end send_message_to_loganalytics
92
+
93
+ private
94
+ def transmission_verb(is_retry)
95
+ if is_retry
96
+ "Resending"
97
+ else
98
+ "Posting"
99
+ end
100
+ end
101
+
102
+ # Try to get the values of the x-ms-error-code and x-ms-request-id headers and content of body, decorate it for printing
103
+ def try_get_info_from_error_response(response)
104
+ output = ""
105
+ if response.headers.include?(:x_ms_error_code)
106
+ output += " [ms-error-code header: #{response.headers[:x_ms_error_code]}]"
107
+ end
108
+ if response.headers.include?(:x_ms_request_id)
109
+ output += " [x-ms-request-id header: #{response.headers[:x_ms_request_id]}]"
110
+ end
111
+ output += " [Response body: #{response.body}]"
112
+ return output
113
+ end
114
+
115
+ end
116
+ end;end;end;