microsoft-sentinel-log-analytics-logstash-output-plugin 1.1.0

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_la/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,91 @@
1
+ # encoding: utf-8
2
+ require "logstash/sentinel_la/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
+ scope = CGI.escape("https://monitor.azure.com//.default")
13
+ @aad_uri = "https://login.microsoftonline.com"
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("%s/%s/oauth2/v2.0/token",@aad_uri, 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
+ @logstashLoganalyticsConfiguration = logstashLoganalyticsConfiguration
23
+ end # def initialize
24
+
25
+ # Public methods
26
+ public
27
+
28
+ def get_aad_token_bearer()
29
+ @token_state[:token_details_mutex].synchronize do
30
+ if is_saved_token_need_refresh()
31
+ refresh_saved_token()
32
+ end
33
+ return @token_state[:access_token]
34
+ end
35
+ end # def get_aad_token_bearer
36
+
37
+ # Private methods
38
+ private
39
+
40
+ def is_saved_token_need_refresh()
41
+ return @token_state[:access_token].nil? || @token_state[:expiry_time].nil? || @token_state[:expiry_time] <= Time.now
42
+ end # def is_saved_token_need_refresh
43
+
44
+ def refresh_saved_token()
45
+ @logger.info("aad token expired - refreshing token.")
46
+
47
+ token_response = post_token_request()
48
+ @token_state[:access_token] = token_response["access_token"]
49
+ @token_state[:expiry_time] = get_token_expiry_time(token_response["expires_in"])
50
+ end # def refresh_saved_token
51
+
52
+ def get_token_expiry_time (expires_in_seconds)
53
+ if (expires_in_seconds.nil? || expires_in_seconds <= 0)
54
+ return Time.now + (60 * 60 * 24) # Refresh anyway in 24 hours
55
+ else
56
+ return Time.now + expires_in_seconds - 1; # Decrease by 1 second to be on the safe side
57
+ end
58
+ end # def get_token_expiry_time
59
+
60
+ # Post the given json to Azure Loganalytics
61
+ def post_token_request()
62
+ # Create REST request header
63
+ headers = get_header()
64
+ while true
65
+ begin
66
+ # Post REST request
67
+ response = RestClient::Request.execute(method: :post, url: @token_request_uri, payload: @token_request_body, headers: headers,
68
+ proxy: @logstashLoganalyticsConfiguration.proxy_aad)
69
+
70
+ if (response.code == 200 || response.code == 201)
71
+ return JSON.parse(response.body)
72
+ end
73
+ rescue RestClient::ExceptionWithResponse => ewr
74
+ @logger.error("Exception while authenticating with AAD API ['#{ewr.response}']")
75
+ rescue Exception => ex
76
+ @logger.trace("Exception while authenticating with AAD API ['#{ex}']")
77
+ end
78
+ @logger.error("Error while authenticating with AAD ('#{@aad_uri}'), retrying in 10 seconds.")
79
+ sleep 10
80
+ end
81
+ end # def post_token_request
82
+
83
+ # Create a header
84
+ def get_header()
85
+ return {
86
+ 'Content-Type' => 'application/x-www-form-urlencoded',
87
+ }
88
+ end # def get_header
89
+
90
+ end # end of class
91
+ end ;end ;end
@@ -0,0 +1,85 @@
1
+ # encoding: utf-8
2
+ require "logstash/sentinel_la/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
+
13
+ require "logstash/sentinel_la/logstashLoganalyticsConfiguration"
14
+ require "logstash/sentinel_la/logAnalyticsAadTokenProvider"
15
+
16
+
17
+ def initialize (logstashLoganalyticsConfiguration)
18
+ @logstashLoganalyticsConfiguration = logstashLoganalyticsConfiguration
19
+ @logger = @logstashLoganalyticsConfiguration.logger
20
+
21
+ la_api_version = "2023-01-01"
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
+ headers = get_header()
33
+
34
+ # Post REST request
35
+
36
+ return RestClient::Request.execute(method: :post, url: @uri, payload: body, headers: headers,
37
+ proxy: @logstashLoganalyticsConfiguration.proxy_endpoint, timeout: 120)
38
+ end # def post_data
39
+
40
+ # Static function to return if the response is OK or else
41
+ def self.is_successfully_posted(response)
42
+ return (response.code >= 200 && response.code < 300 ) ? true : false
43
+ end # def self.is_successfully_posted
44
+
45
+ private
46
+
47
+ # Create a header for the given length
48
+ def get_header()
49
+ # Getting an authorization token bearer (if the token is expired, the method will post a request to get a new authorization token)
50
+ token_bearer = @aadTokenProvider.get_aad_token_bearer()
51
+
52
+ headers = {
53
+ 'Content-Type' => 'application/json',
54
+ 'Authorization' => sprintf("Bearer %s", token_bearer),
55
+ 'User-Agent' => @userAgent
56
+ }
57
+
58
+ if @logstashLoganalyticsConfiguration.compress_data
59
+ headers = headers.merge({
60
+ 'Content-Encoding' => 'gzip'
61
+ })
62
+ end
63
+
64
+ return headers
65
+ end # def get_header
66
+
67
+ def ruby_agent_version()
68
+ case RUBY_ENGINE
69
+ when 'jruby'
70
+ "jruby/#{JRUBY_VERSION} (#{RUBY_VERSION}p#{RUBY_PATCHLEVEL})"
71
+ else
72
+ "#{RUBY_ENGINE}/#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}"
73
+ end
74
+ end
75
+
76
+ def architecture()
77
+ "#{RbConfig::CONFIG['host_os']} #{RbConfig::CONFIG['host_cpu']}"
78
+ end
79
+
80
+ def getUserAgent()
81
+ "SentinelLogstashPlugin|#{LogStash::Outputs::MicrosoftSentinelOutputInternal::VERSION}|#{architecture}|#{ruby_agent_version}"
82
+ end #getUserAgent
83
+
84
+ end # end of class
85
+ end ;end ;end
@@ -0,0 +1,157 @@
1
+ # encoding: utf-8
2
+
3
+ require "logstash/sentinel_la/logstashLoganalyticsConfiguration"
4
+ require "logstash/sentinel_la/customSizeBasedBuffer"
5
+ require "logstash/sentinel_la/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_la/logstashLoganalyticsConfiguration"
4
+ require "logstash/sentinel_la/customSizeBasedBuffer"
5
+ require "logstash/sentinel_la/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,142 @@
1
+ # encoding: utf-8
2
+
3
+ require "logstash/sentinel_la/logAnalyticsClient"
4
+ require "logstash/sentinel_la/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
+ # 408 reqeust timeout and client timeout (open/read) will retry the current message
50
+ # 429 (too many requests) are retried forever
51
+ # All other http errors are retried for total every of @logstashLoganalyticsConfiguration.RETRANSMISSION_DELAY until @logstashLoganalyticsConfiguration.retransmission_time seconds passed
52
+ begin
53
+ @logger.debug(transmission_verb(is_retry) + " log batch (amount of documents: #{amount_of_documents}) to DCR stream #{@logstashLoganalyticsConfiguration.dcr_stream_name} to #{api_name}.")
54
+ response = @client.post_data(call_payload)
55
+
56
+ if LogAnalyticsClient.is_successfully_posted(response)
57
+ request_id = get_request_id_from_response(response)
58
+ @logger.info("Successfully posted #{amount_of_documents} logs into log analytics DCR stream [#{@logstashLoganalyticsConfiguration.dcr_stream_name}] x-ms-request-id [#{request_id}].")
59
+ return
60
+ else
61
+ @logger.trace("Rest client response ['#{response}']")
62
+ @logger.error("#{api_name} request failed. Error code: #{response.code} #{try_get_info_from_error_response(response)}")
63
+ end
64
+ rescue RestClient::Exceptions::Timeout => eto
65
+ @logger.trace("Timeout exception ['#{eto.display}'] when posting data to #{api_name}. Rest client response ['#{eto.response.display}']. [amount_of_documents=#{amount_of_documents}]")
66
+ @logger.error("Timeout exception while posting data to #{api_name}. [Exception: '#{eto}'] [amount of documents=#{amount_of_documents}]'")
67
+ force_retry = true
68
+
69
+ rescue RestClient::ExceptionWithResponse => ewr
70
+ response = ewr.response
71
+ @logger.trace("Exception in posting data to #{api_name}. Rest client response ['#{ewr.response}']. [amount_of_documents=#{amount_of_documents} request payload=#{call_payload}]")
72
+ @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}]'")
73
+
74
+ if ewr.http_code.to_f == 400
75
+ @logger.info("Not trying to resend since exception http code is #{ewr.http_code}")
76
+ return
77
+ elsif ewr.http_code.to_f == 408
78
+ force_retry = true
79
+ elsif ewr.http_code.to_f == 429
80
+ # thrutteling detected, backoff before resending
81
+ parsed_retry_after = response.headers.include?(:retry_after) ? response.headers[:retry_after].to_i : 0
82
+ seconds_to_sleep = parsed_retry_after > 0 ? parsed_retry_after : 30
83
+
84
+ #force another retry even if the next iteration of the loop will be after the retransmission_timeout
85
+ force_retry = true
86
+ end
87
+ rescue Exception => ex
88
+ @logger.trace("Exception in posting data to #{api_name}.[amount_of_documents=#{amount_of_documents} request payload=#{call_payload}]")
89
+ @logger.error("Exception in posting data to #{api_name}. [Exception: '#{ex}, amount of documents=#{amount_of_documents}]'")
90
+ end
91
+ is_retry = true
92
+ @logger.info("Retrying transmission to #{api_name} in #{seconds_to_sleep} seconds.")
93
+
94
+ sleep seconds_to_sleep
95
+ end
96
+
97
+ @logger.error("Could not resend #{amount_of_documents} documents, message is dropped after retransmission_time exceeded.")
98
+ @logger.trace("Documents (#{amount_of_documents}) dropped. [call_payload=#{call_payload}]")
99
+ end # end send_message_to_loganalytics
100
+
101
+ private
102
+ def transmission_verb(is_retry)
103
+ if is_retry
104
+ "Resending"
105
+ else
106
+ "Posting"
107
+ end
108
+ end
109
+
110
+ def get_request_id_from_response(response)
111
+ output =""
112
+ begin
113
+ if !response.nil? && response.headers.include?(:x_ms_request_id)
114
+ output += response.headers[:x_ms_request_id]
115
+ end
116
+ rescue Exception => ex
117
+ @logger.debug("Error while getting reqeust id from success response headers: #{ex.display}")
118
+ end
119
+ return output
120
+ end
121
+
122
+ # 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
123
+ def try_get_info_from_error_response(response)
124
+ begin
125
+ output = ""
126
+ if !response.nil?
127
+ if response.headers.include?(:x_ms_error_code)
128
+ output += " [ms-error-code header: #{response.headers[:x_ms_error_code]}]"
129
+ end
130
+ if response.headers.include?(:x_ms_request_id)
131
+ output += " [x-ms-request-id header: #{response.headers[:x_ms_request_id]}]"
132
+ end
133
+ end
134
+ return output
135
+ rescue Exception => ex
136
+ @logger.debug("Error while getting reqeust id from headers: #{ex.display}")
137
+ return " [response content: #{response.to_s}]"
138
+ end
139
+ end
140
+
141
+ end
142
+ end;end;end;