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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +2 -0
- data/Gemfile +2 -0
- data/LICENSE +21 -0
- data/README.md +230 -0
- data/lib/logstash/outputs/microsoft-sentinel-logstash-output-plugin.rb +103 -0
- data/lib/logstash/sentinel/customSizeBasedBuffer.rb +293 -0
- data/lib/logstash/sentinel/eventsHandler.rb +58 -0
- data/lib/logstash/sentinel/logAnalyticsAadTokenProvider.rb +93 -0
- data/lib/logstash/sentinel/logAnalyticsClient.rb +90 -0
- data/lib/logstash/sentinel/logStashAutoResizeBuffer.rb +157 -0
- data/lib/logstash/sentinel/logStashCompressedStream.rb +144 -0
- data/lib/logstash/sentinel/logStashEventsBatcher.rb +116 -0
- data/lib/logstash/sentinel/logsSender.rb +47 -0
- data/lib/logstash/sentinel/logstashLoganalyticsConfiguration.rb +214 -0
- data/lib/logstash/sentinel/sampleFileCreator.rb +61 -0
- data/lib/logstash/sentinel/version.rb +10 -0
- data/microsoft-sentinel-logstash-output-plugin.gemspec +27 -0
- metadata +125 -0
@@ -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;
|