logstash-output-kusto 2.1.6-java → 2.2.0-java

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.
@@ -6,6 +6,7 @@ require 'logstash/errors'
6
6
 
7
7
  require 'logstash/outputs/kusto/ingestor'
8
8
  require 'logstash/outputs/kusto/interval'
9
+ require 'logstash/outputs/kusto/streaming_chunker'
9
10
 
10
11
  ##
11
12
  # This plugin sends messages to Azure Kusto in batches.
@@ -28,7 +29,7 @@ class LogStash::Outputs::Kusto < LogStash::Outputs::Base
28
29
  #
29
30
  # If you use an absolute path you cannot start with a dynamic string.
30
31
  # E.g: `/%{myfield}/`, `/test-%{myfield}/` are not valid paths
31
- config :path, validate: :string, required: true
32
+ config :path, validate: :string, required: false
32
33
 
33
34
  # Flush interval (in seconds) for flushing writes to files.
34
35
  # 0 will flush on every message. Increase this value to recude IO calls but keep
@@ -110,6 +111,26 @@ class LogStash::Outputs::Kusto < LogStash::Outputs::Base
110
111
  # starts processing them in the main thread (not healthy)
111
112
  config :upload_queue_size, validate: :number, default: 30
112
113
 
114
+ # Queued ingestion is optimized for throughput. Streaming ingestion is
115
+ # optimized for low latency and automatically falls back to queued ingestion.
116
+ config :ingestion_mode, validate: %w[queued streaming], default: 'queued'
117
+
118
+ # Maximum encoded bytes in one streaming request. Events are never split.
119
+ config :streaming_max_request_bytes, validate: :number, default: 1_048_576
120
+
121
+ # Additional retries for transient errors that escape the streaming client.
122
+ config :streaming_max_retry_attempts, validate: :number, default: 2
123
+
124
+ # Initial retry delay. Subsequent streaming retries use exponential backoff.
125
+ config :streaming_retry_backoff_seconds, validate: :number, default: 1
126
+
127
+ # Limit concurrent requests issued by shared Logstash pipeline workers.
128
+ config :streaming_concurrent_requests, validate: :number, default: 4
129
+
130
+ # Local durable spool for streaming requests. A stable default is
131
+ # derived from the Kusto destination so files can be recovered after restart.
132
+ config :streaming_temp_directory, validate: :string, required: false
133
+
113
134
  # Host of the proxy , is an optional field. Can connect directly
114
135
  config :proxy_host, validate: :string, required: false
115
136
 
@@ -132,43 +153,199 @@ class LogStash::Outputs::Kusto < LogStash::Outputs::Base
132
153
  final_mapping = mapping
133
154
  end
134
155
 
135
- # TODO: add id to the tmp path to support multiple outputs of the same type.
136
- # TODO: Fix final_mapping when dynamic routing is supported
137
- # add fields from the meta that will note the destination of the events in the file
138
- @path = if dynamic_event_routing
139
- File.expand_path("#{path}.%{[@metadata][database]}.%{[@metadata][table]}.%{[@metadata][final_mapping]}")
140
- else
141
- File.expand_path("#{path}.#{database}.#{table}")
142
- end
156
+ if ingestion_mode == 'queued'
157
+ raise LogStash::ConfigurationError, 'path is required for queued ingestion.' if path.nil? || path.empty?
158
+
159
+ # TODO: add id to the tmp path to support multiple outputs of the same type.
160
+ # TODO: Fix final_mapping when dynamic routing is supported
161
+ @path = if dynamic_event_routing
162
+ File.expand_path("#{path}.%{[@metadata][database]}.%{[@metadata][table]}.%{[@metadata][final_mapping]}")
163
+ else
164
+ File.expand_path("#{path}.#{database}.#{table}")
165
+ end
166
+
167
+ validate_path
168
+
169
+ @file_root = if path_with_field_ref?
170
+ extract_file_root
171
+ else
172
+ File.dirname(path)
173
+ end
174
+ @failure_path = File.join(@file_root, @filename_failure)
175
+ else
176
+ validate_streaming_config
177
+ @streaming_chunker = StreamingChunker.new(streaming_max_request_bytes.to_i)
178
+ require 'digest'
179
+ destination_id = Digest::SHA256.hexdigest(
180
+ [ingest_url, database, table, final_mapping].join("\0")
181
+ )[0, 20]
182
+ @streaming_temp_directory = File.expand_path(
183
+ streaming_temp_directory ||
184
+ File.join(
185
+ LogStash::SETTINGS.get('path.data'),
186
+ 'plugins',
187
+ 'logstash-output-kusto',
188
+ destination_id
189
+ )
190
+ )
191
+ @streaming_dir_mode = @dir_mode == -1 ? 0o700 : @dir_mode.to_i
192
+ @streaming_file_mode = @file_mode == -1 ? 0o600 : @file_mode.to_i
193
+ validate_streaming_modes
194
+ prepare_streaming_spool_directory
195
+ acquire_streaming_spool_lock
196
+ @streaming_metric = metric.namespace(:streaming)
197
+ %i[
198
+ requests
199
+ events
200
+ bytes
201
+ streamed
202
+ queued_fallback
203
+ pending
204
+ final_non_success
205
+ oversized_events
206
+ failures
207
+ retry_cycles
208
+ ].each { |counter| @streaming_metric.increment(counter, 0) }
209
+ end
210
+
211
+ begin
212
+ executor = Concurrent::ThreadPoolExecutor.new(
213
+ min_threads: 1,
214
+ max_threads: ingestion_mode == 'queued' ? upload_concurrent_count : streaming_concurrent_requests,
215
+ max_queue: upload_queue_size,
216
+ fallback_policy: :caller_runs
217
+ )
218
+
219
+ @ingestor = Ingestor.new(
220
+ ingest_url,
221
+ app_id,
222
+ app_key,
223
+ app_tenant,
224
+ managed_identity,
225
+ cli_auth,
226
+ database,
227
+ table,
228
+ final_mapping,
229
+ delete_temp_files,
230
+ proxy_host,
231
+ proxy_port,
232
+ proxy_protocol,
233
+ @logger,
234
+ executor,
235
+ ingestion_mode,
236
+ streaming_max_retry_attempts.to_i,
237
+ streaming_retry_backoff_seconds.to_f,
238
+ nil,
239
+ nil,
240
+ ingestion_mode == 'streaming' ? @streaming_metric : nil
241
+ )
242
+
243
+ if recovery
244
+ ingestion_mode == 'queued' ? recover_past_files : recover_streaming_files
245
+ end
246
+
247
+ @last_stale_cleanup_cycle = Time.now
248
+
249
+ @flush_interval = @flush_interval.to_i
250
+ if ingestion_mode == 'queued' && @flush_interval > 0
251
+ @flusher = Interval.start(@flush_interval, -> { flush_pending_files })
252
+ end
253
+
254
+ if ingestion_mode == 'queued' && (@stale_cleanup_type == 'interval') && (@stale_cleanup_interval > 0)
255
+ @cleaner = Interval.start(stale_cleanup_interval, -> { close_stale_files })
256
+ end
257
+ rescue
258
+ release_streaming_spool_lock
259
+ raise
260
+ end
261
+ end
262
+
263
+ private
264
+ def validate_streaming_config
265
+ if streaming_max_request_bytes.to_i <= 0
266
+ raise LogStash::ConfigurationError,
267
+ 'streaming_max_request_bytes must be greater than zero.'
268
+ end
269
+
270
+ if streaming_max_retry_attempts.to_i < 0
271
+ raise LogStash::ConfigurationError,
272
+ 'streaming_max_retry_attempts must be zero or greater.'
273
+ end
274
+
275
+ if streaming_retry_backoff_seconds.to_f <= 0
276
+ raise LogStash::ConfigurationError,
277
+ 'streaming_retry_backoff_seconds must be greater than zero.'
278
+ end
143
279
 
144
- validate_path
280
+ if streaming_concurrent_requests.to_i <= 0
281
+ raise LogStash::ConfigurationError,
282
+ 'streaming_concurrent_requests must be greater than zero.'
283
+ end
284
+ end
145
285
 
146
- @file_root = if path_with_field_ref?
147
- extract_file_root
148
- else
149
- File.dirname(path)
150
- end
151
- @failure_path = File.join(@file_root, @filename_failure)
286
+ private
287
+ def validate_streaming_modes
288
+ if (@streaming_dir_mode & 0o022).positive?
289
+ raise LogStash::ConfigurationError,
290
+ 'dir_mode must not allow group or world writes for streaming.'
291
+ end
152
292
 
153
- executor = Concurrent::ThreadPoolExecutor.new(min_threads: 1,
154
- max_threads: upload_concurrent_count,
155
- max_queue: upload_queue_size,
156
- fallback_policy: :caller_runs)
293
+ return unless (@streaming_file_mode & 0o022).positive?
157
294
 
158
- @ingestor = Ingestor.new(ingest_url, app_id, app_key, app_tenant, managed_identity, cli_auth, database, table, final_mapping, delete_temp_files, proxy_host, proxy_port,proxy_protocol, @logger, executor)
295
+ raise LogStash::ConfigurationError,
296
+ 'file_mode must not allow group or world writes for streaming.'
297
+ end
159
298
 
160
- # send existing files
161
- recover_past_files if recovery
299
+ private
300
+ def prepare_streaming_spool_directory
301
+ if File.symlink?(@streaming_temp_directory)
302
+ raise LogStash::ConfigurationError,
303
+ "Streaming spool directory must not be a symlink: #{@streaming_temp_directory}"
304
+ end
162
305
 
163
- @last_stale_cleanup_cycle = Time.now
306
+ FileUtils.mkdir_p(@streaming_temp_directory, mode: @streaming_dir_mode)
307
+ File.chmod(@streaming_dir_mode, @streaming_temp_directory)
308
+ @streaming_temp_directory = File.realpath(@streaming_temp_directory)
309
+ validate_streaming_spool_directory
310
+ validate_streaming_spool_parents unless Gem.win_platform?
311
+ end
164
312
 
165
- @flush_interval = @flush_interval.to_i
166
- if @flush_interval > 0
167
- @flusher = Interval.start(@flush_interval, -> { flush_pending_files })
313
+ private
314
+ def validate_streaming_spool_directory
315
+ stat = File.lstat(@streaming_temp_directory)
316
+ unless stat.directory? && !stat.symlink?
317
+ raise LogStash::ConfigurationError,
318
+ "Streaming spool path is not a directory: #{@streaming_temp_directory}"
168
319
  end
169
320
 
170
- if (@stale_cleanup_type == 'interval') && (@stale_cleanup_interval > 0)
171
- @cleaner = Interval.start(stale_cleanup_interval, -> { close_stale_files })
321
+ return if Gem.win_platform? || stat.uid == Process.uid
322
+
323
+ raise LogStash::ConfigurationError,
324
+ "Streaming spool directory is not owned by the Logstash user: #{@streaming_temp_directory}"
325
+ end
326
+
327
+ private
328
+ def validate_streaming_spool_parents
329
+ current = File.dirname(@streaming_temp_directory)
330
+ loop do
331
+ stat = File.lstat(current)
332
+ mode = stat.mode & 0o7777
333
+ owned_by_trusted_user = stat.uid == Process.uid || stat.uid.zero?
334
+ sticky_shared_directory = stat.sticky? && (mode & 0o002).positive?
335
+
336
+ unless owned_by_trusted_user || ((mode & 0o200).zero? && (mode & 0o022).zero?)
337
+ raise LogStash::ConfigurationError,
338
+ "Streaming spool parent is owned by an untrusted user: #{current}"
339
+ end
340
+ if (mode & 0o022).positive? && !sticky_shared_directory
341
+ raise LogStash::ConfigurationError,
342
+ "Streaming spool parent is writable by untrusted users: #{current}"
343
+ end
344
+
345
+ parent = File.dirname(current)
346
+ break if parent == current
347
+
348
+ current = parent
172
349
  end
173
350
  end
174
351
 
@@ -198,6 +375,16 @@ class LogStash::Outputs::Kusto < LogStash::Outputs::Base
198
375
 
199
376
  public
200
377
  def multi_receive_encoded(events_and_encoded)
378
+ if ingestion_mode == 'streaming'
379
+ streaming_files = write_streaming_chunks(
380
+ @streaming_chunker.chunks(events_and_encoded.map(&:last))
381
+ )
382
+ streaming_files.each do |file|
383
+ @ingestor.upload_async(file[:path], delete_temp_files)
384
+ end
385
+ return
386
+ end
387
+
201
388
  encoded_by_path = Hash.new { |h, k| h[k] = [] }
202
389
 
203
390
  events_and_encoded.each do |event, encoded|
@@ -220,22 +407,28 @@ class LogStash::Outputs::Kusto < LogStash::Outputs::Base
220
407
  def close
221
408
  @flusher.stop unless @flusher.nil?
222
409
  @cleaner.stop unless @cleaner.nil?
223
- @io_mutex.synchronize do
224
- @logger.debug('Close: closing files')
225
-
226
- @files.each do |path, fd|
227
- begin
228
- fd.close
229
- @logger.debug("Closed file #{path}", fd: fd)
230
-
231
- kusto_send_file(path)
232
- rescue Exception => e
233
- @logger.error('Exception while flushing and closing files.', exception: e)
410
+ if ingestion_mode == 'queued'
411
+ @io_mutex.synchronize do
412
+ @logger.debug('Close: closing files')
413
+
414
+ @files.each do |path, fd|
415
+ begin
416
+ fd.close
417
+ @logger.debug("Closed file #{path}", fd: fd)
418
+
419
+ kusto_send_file(path)
420
+ rescue Exception => e
421
+ @logger.error('Exception while flushing and closing files.', exception: e)
422
+ end
234
423
  end
235
424
  end
236
425
  end
237
426
 
238
- @ingestor.stop unless @ingestor.nil?
427
+ begin
428
+ @ingestor.stop unless @ingestor.nil?
429
+ ensure
430
+ release_streaming_spool_lock
431
+ end
239
432
  end
240
433
 
241
434
  private
@@ -398,6 +591,145 @@ class LogStash::Outputs::Kusto < LogStash::Outputs::Base
398
591
  @logger.warn('No such file or directory', exception: e.class, message: e.message, path: new_path, backtrace: e.backtrace)
399
592
  end
400
593
  end
594
+
595
+ private
596
+ def write_streaming_chunks(chunks)
597
+ require 'securerandom'
598
+
599
+ return [] if chunks.empty?
600
+
601
+ batch_id = SecureRandom.uuid
602
+ temporary_directory = File.join(@streaming_temp_directory, ".batch-#{batch_id}.tmp")
603
+ ready_directory = File.join(@streaming_temp_directory, "batch-#{batch_id}.ready")
604
+ files = []
605
+ committed = false
606
+ Dir.mkdir(temporary_directory, @streaming_dir_mode)
607
+
608
+ chunks.each_with_index do |chunk, index|
609
+ source_id = SecureRandom.uuid
610
+ path = File.join(
611
+ temporary_directory,
612
+ format('stream-%06d-%s.json', index, source_id)
613
+ )
614
+ bytes = chunk.sum(&:bytesize)
615
+ write_streaming_file(path, chunk)
616
+ files << { path: path, bytes: bytes, events: chunk.length }
617
+ end
618
+ fsync_directory(temporary_directory)
619
+ File.rename(temporary_directory, ready_directory)
620
+ committed = true
621
+ fsync_directory(@streaming_temp_directory)
622
+ files.each do |file|
623
+ file[:path] = File.join(ready_directory, File.basename(file[:path]))
624
+ end
625
+
626
+ files.each do |file|
627
+ @streaming_metric.increment(:requests)
628
+ @streaming_metric.increment(:events, file[:events])
629
+ @streaming_metric.increment(:bytes, file[:bytes])
630
+ @streaming_metric.increment(:oversized_events) if
631
+ file[:events] == 1 && file[:bytes] > streaming_max_request_bytes.to_i
632
+ end
633
+ files
634
+ rescue
635
+ FileUtils.rm_rf(temporary_directory) if temporary_directory
636
+ FileUtils.rm_rf(ready_directory) if ready_directory && !committed
637
+ @streaming_metric.increment(:failures)
638
+ raise
639
+ end
640
+
641
+ private
642
+ def write_streaming_file(path, encoded_events)
643
+ flags = File::WRONLY | File::CREAT | File::EXCL
644
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
645
+ file = File.new(path, flags, @streaming_file_mode)
646
+ begin
647
+ encoded_events.each { |encoded| file.write(encoded) }
648
+ file.flush
649
+ file.fsync
650
+ ensure
651
+ file.close
652
+ end
653
+ end
654
+
655
+ private
656
+ def recover_streaming_files
657
+ Dir.glob(File.join(@streaming_temp_directory, '.batch-*.tmp')).each do |directory|
658
+ @logger.warn('Discarding incomplete streaming spool batch.', path: directory)
659
+ FileUtils.rm_rf(directory)
660
+ end
661
+
662
+ Dir.glob(File.join(@streaming_temp_directory, 'batch-*.ready', 'stream-*.json')).sort.each do |file|
663
+ validate_recovered_streaming_file(file)
664
+ @logger.info('Recovering streaming spool file.', path: file)
665
+ @ingestor.upload_async(file, delete_temp_files)
666
+ end
667
+ end
668
+
669
+ private
670
+ def validate_recovered_streaming_file(file)
671
+ batch_directory = File.dirname(file)
672
+ batch_stat = File.lstat(batch_directory)
673
+ file_stat = File.lstat(file)
674
+ safe_batch = batch_stat.directory? && !batch_stat.symlink? &&
675
+ safe_streaming_spool_stat?(batch_stat)
676
+ safe_file = file_stat.file? && !file_stat.symlink? &&
677
+ safe_streaming_spool_stat?(file_stat)
678
+ return if safe_batch && safe_file
679
+
680
+ raise LogStash::ConfigurationError,
681
+ "Streaming recovery rejected an unsafe spool file: #{file}"
682
+ end
683
+
684
+ private
685
+ def safe_streaming_spool_stat?(stat)
686
+ owner_is_safe = Gem.win_platform? || stat.uid == Process.uid
687
+ owner_is_safe && (stat.mode & 0o022).zero?
688
+ end
689
+
690
+ private
691
+ def fsync_directory(directory)
692
+ return if Gem.win_platform?
693
+
694
+ File.open(directory, File::RDONLY) { |file| file.fsync }
695
+ rescue SystemCallError => e
696
+ unsupported_errors = [Errno::EINVAL::Errno]
697
+ unsupported_errors << Errno::EISDIR::Errno if defined?(Errno::EISDIR)
698
+ unsupported_errors << Errno::ENOTSUP::Errno if defined?(Errno::ENOTSUP)
699
+ raise unless unsupported_errors.include?(e.errno)
700
+
701
+ @logger.debug('Directory fsync is not supported on this platform.', path: directory)
702
+ end
703
+
704
+ private
705
+ def acquire_streaming_spool_lock
706
+ lock_path = File.join(@streaming_temp_directory, '.lock')
707
+ if File.symlink?(lock_path)
708
+ raise LogStash::ConfigurationError,
709
+ "Streaming spool lock must not be a symlink: #{lock_path}"
710
+ end
711
+
712
+ flags = File::RDWR | File::CREAT
713
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
714
+ lock_file = File.open(lock_path, flags, @streaming_file_mode)
715
+ File.chmod(@streaming_file_mode, lock_path)
716
+ unless lock_file.flock(File::LOCK_EX | File::LOCK_NB)
717
+ lock_file.close
718
+ raise LogStash::ConfigurationError,
719
+ "Streaming spool directory is already in use: #{@streaming_temp_directory}"
720
+ end
721
+
722
+ @streaming_lock_file = lock_file
723
+ end
724
+
725
+ private
726
+ def release_streaming_spool_lock
727
+ return if @streaming_lock_file.nil?
728
+
729
+ @streaming_lock_file.flock(File::LOCK_UN)
730
+ @streaming_lock_file.close
731
+ @streaming_lock_file = nil
732
+ end
401
733
  end
402
734
 
403
735
  # wrapper class