fluent-plugin-kusto 0.0.1.beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,370 @@
1
+ # rubocop:disable all
2
+ # frozen_string_literal: true
3
+
4
+ require 'ostruct'
5
+ require_relative '../helper'
6
+ require 'fluent/test/driver/output'
7
+ require 'fluent/plugin/out_kusto'
8
+ require 'mocha/test_unit'
9
+
10
+ class KustoOutputWriteTest < Test::Unit::TestCase
11
+ setup do
12
+ Fluent::Test.setup
13
+ @driver = Fluent::Test::Driver::Output.new(Fluent::Plugin::KustoOutput).configure(<<-CONF)
14
+ @type kusto
15
+ endpoint https://example.kusto.windows.net
16
+ database_name testdb
17
+ table_name testtable
18
+ client_id dummy-client-id
19
+ client_secret dummy-secret
20
+ tenant_id dummy-tenant
21
+ buffered true
22
+ auth_type aad
23
+ CONF
24
+ end
25
+
26
+ class FakeKustoError < StandardError
27
+ def initialize(msg, permanent)
28
+ super(msg)
29
+ @permanent = permanent
30
+ end
31
+
32
+ def permanent?
33
+ @permanent
34
+ end
35
+
36
+ def is_permanent?
37
+ permanent?
38
+ end
39
+
40
+ def failure_code
41
+ nil
42
+ end
43
+
44
+ def failure_sub_code
45
+ nil
46
+ end
47
+ end
48
+
49
+ def logger_stub
50
+ m = mock
51
+ m.stubs(:debug)
52
+ m.stubs(:error)
53
+ m.stubs(:info)
54
+ m.stubs(:warn)
55
+ m
56
+ end
57
+
58
+ def ingester_stub
59
+ m = mock
60
+ m.stubs(:upload_data_to_blob_and_queue)
61
+ m
62
+ end
63
+
64
+ def set_mocks(ingester: nil, logger: nil)
65
+ @driver.instance.instance_variable_set(:@ingester, ingester) if ingester
66
+ @driver.instance.instance_variable_set(:@logger, logger) if logger
67
+ end
68
+
69
+ def chunk_stub(data: 'testdata', tag: 'test.tag', unique_id: 'uniqueid'.b, metadata: nil)
70
+ c = mock
71
+ c.stubs(:read).returns(data)
72
+ c.stubs(:metadata).returns(metadata || OpenStruct.new(tag: tag))
73
+ c.stubs(:unique_id).returns(unique_id)
74
+ c
75
+ end
76
+
77
+ test 'write uploads compressed data to blob and queue' do
78
+ ingester_mock = mock
79
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once.with do |_data, blob_name, _db, _table|
80
+ assert_match(/fluentd_event_worker\d+_test\.tag_[0-9a-f]+\.json\.gz/, blob_name)
81
+ true
82
+ end
83
+ logger_mock = logger_stub
84
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
85
+ chunk = chunk_stub
86
+ assert_nothing_raised { @driver.instance.write(chunk) }
87
+ end
88
+
89
+ test 'write raises error on permanent Kusto error' do
90
+ ingester_mock = mock
91
+ kusto_error = FakeKustoError.new('permanent fail', true)
92
+ KustoErrorHandler.stubs(:extract_kusto_error_type).returns(:permanent)
93
+ KustoErrorHandler.stubs(:from_kusto_error_type).returns(kusto_error)
94
+ ingester_mock.stubs(:upload_data_to_blob_and_queue).raises(StandardError, 'fail')
95
+ logger_mock = mock
96
+ logger_mock.stubs(:debug)
97
+ logger_mock.expects(:error).at_least_once
98
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
99
+ chunk = chunk_stub
100
+ assert_raise(FakeKustoError) { @driver.instance.write(chunk) }
101
+ end
102
+
103
+ test 'write raises error on non-permanent Kusto error (triggers retry)' do
104
+ ingester_mock = mock
105
+ kusto_error = FakeKustoError.new('transient fail', false)
106
+ KustoErrorHandler.stubs(:extract_kusto_error_type).returns(:transient)
107
+ KustoErrorHandler.stubs(:from_kusto_error_type).returns(kusto_error)
108
+ ingester_mock.stubs(:upload_data_to_blob_and_queue).raises(StandardError, 'fail')
109
+ logger_mock = mock
110
+ logger_mock.stubs(:debug)
111
+ logger_mock.expects(:error).at_least_once
112
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
113
+ chunk = chunk_stub
114
+ assert_raise(FakeKustoError) { @driver.instance.write(chunk) }
115
+ end
116
+
117
+ test 'write raises error on unknown error' do
118
+ ingester_mock = mock
119
+ KustoErrorHandler.stubs(:extract_kusto_error_type).returns(nil)
120
+ ingester_mock.stubs(:upload_data_to_blob_and_queue).raises(IOError, 'io fail')
121
+ logger_mock = mock
122
+ logger_mock.stubs(:debug)
123
+ logger_mock.expects(:error).at_least_once
124
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
125
+ chunk = chunk_stub
126
+ assert_raise(IOError) { @driver.instance.write(chunk) }
127
+ end
128
+
129
+ test 'write calls logger.info on success' do
130
+ ingester_mock = ingester_stub
131
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once
132
+ logger_mock = logger_stub
133
+ logger_mock.stubs(:info)
134
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
135
+ chunk = chunk_stub
136
+ assert_nothing_raised { @driver.instance.write(chunk) }
137
+ end
138
+
139
+ test 'write calls logger.error with correct message on permanent error' do
140
+ ingester_mock = mock
141
+ kusto_error = FakeKustoError.new('permanent fail', true)
142
+ KustoErrorHandler.stubs(:extract_kusto_error_type).returns(:permanent)
143
+ KustoErrorHandler.stubs(:from_kusto_error_type).returns(kusto_error)
144
+ ingester_mock.stubs(:upload_data_to_blob_and_queue).raises(StandardError, 'fail')
145
+ logger_mock = mock
146
+ logger_mock.stubs(:debug)
147
+ logger_mock.stubs(:error)
148
+ logger_mock.expects(:error).with(regexp_matches(/Dropping chunk .* due to permanent Kusto error/)).at_least_once
149
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
150
+ chunk = chunk_stub
151
+ assert_raise(FakeKustoError) { @driver.instance.write(chunk) }
152
+ end
153
+
154
+ test 'write works with different chunk metadata' do
155
+ ingester_mock = mock
156
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once
157
+ logger_mock = mock
158
+ logger_mock.stubs(:debug)
159
+ logger_mock.stubs(:error)
160
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
161
+ chunk = chunk_stub(tag: 'other.tag', unique_id: 'otherid'.b)
162
+ assert_nothing_raised { @driver.instance.write(chunk) }
163
+ end
164
+
165
+ test 'write handles empty chunk data' do
166
+ ingester_mock = ingester_stub
167
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once
168
+ logger_mock = logger_stub
169
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
170
+ chunk = chunk_stub(data: '')
171
+ assert_nothing_raised { @driver.instance.write(chunk) }
172
+ end
173
+
174
+ test 'write handles chunk.read raising error' do
175
+ ingester_mock = ingester_stub
176
+ logger_mock = logger_stub
177
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
178
+ chunk = chunk_stub
179
+ chunk.stubs(:read).raises(IOError, 'read fail')
180
+ assert_raise(IOError) { @driver.instance.write(chunk) }
181
+ end
182
+
183
+ test 'write retries on non-permanent Kusto error up to buffer retry_max_times' do
184
+ # Simulate Fluentd's retry mechanism by calling write multiple times
185
+ ingester_mock = mock
186
+ kusto_error = FakeKustoError.new('transient fail', false)
187
+ KustoErrorHandler.stubs(:extract_kusto_error_type).returns(:transient)
188
+ KustoErrorHandler.stubs(:from_kusto_error_type).returns(kusto_error)
189
+ ingester_mock.stubs(:upload_data_to_blob_and_queue).raises(StandardError, 'fail')
190
+ logger_mock = mock
191
+ logger_mock.stubs(:debug)
192
+ logger_mock.stubs(:error)
193
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
194
+ chunk = chunk_stub
195
+ # Simulate retry_max_times = 3
196
+ 3.times do
197
+ assert_raise(FakeKustoError) { @driver.instance.write(chunk) }
198
+ end
199
+ end
200
+
201
+ test 'write stops retrying if permanent error occurs after retries' do
202
+ ingester_mock = mock
203
+ # First 2 attempts: non-permanent error, 3rd attempt: permanent error
204
+ kusto_error_transient = FakeKustoError.new('transient fail', false)
205
+ kusto_error_permanent = FakeKustoError.new('permanent fail', true)
206
+ KustoErrorHandler.stubs(:extract_kusto_error_type).returns(:transient, :transient, :permanent)
207
+ KustoErrorHandler.stubs(:from_kusto_error_type).returns(kusto_error_transient, kusto_error_transient,
208
+ kusto_error_permanent)
209
+ ingester_mock.stubs(:upload_data_to_blob_and_queue).raises(StandardError, 'fail')
210
+ logger_mock = mock
211
+ logger_mock.stubs(:debug)
212
+ logger_mock.stubs(:error)
213
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
214
+ chunk = chunk_stub
215
+ 2.times { assert_raise(FakeKustoError) { @driver.instance.write(chunk) } }
216
+ assert_raise(FakeKustoError) { @driver.instance.write(chunk) } # Should be permanent error
217
+ end
218
+
219
+ test 'write succeeds after retries if error goes away' do
220
+ ingester_mock = mock
221
+ kusto_error = FakeKustoError.new('transient fail', false)
222
+ KustoErrorHandler.stubs(:extract_kusto_error_type).returns(:transient, :transient, nil)
223
+ KustoErrorHandler.stubs(:from_kusto_error_type).returns(kusto_error, kusto_error)
224
+ # First 2 attempts raise, 3rd attempt succeeds
225
+ ingester_mock.stubs(:upload_data_to_blob_and_queue).raises(StandardError, 'fail').then.raises(StandardError,
226
+ 'fail').then.returns(true)
227
+ logger_mock = mock
228
+ logger_mock.stubs(:debug)
229
+ logger_mock.stubs(:error)
230
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
231
+ chunk = chunk_stub
232
+ 2.times { assert_raise(FakeKustoError) { @driver.instance.write(chunk) } }
233
+ assert_nothing_raised { @driver.instance.write(chunk) }
234
+ end
235
+
236
+ test 'write handles chunk with nil metadata' do
237
+ ingester_mock = ingester_stub
238
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once
239
+ logger_mock = logger_stub
240
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
241
+ chunk = chunk_stub(metadata: nil)
242
+ assert_nothing_raised { @driver.instance.write(chunk) }
243
+ end
244
+
245
+ test 'write handles chunk with nil unique_id' do
246
+ ingester_mock = ingester_stub
247
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once
248
+ logger_mock = logger_stub
249
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
250
+ chunk = chunk_stub(unique_id: nil)
251
+ assert_nothing_raised { @driver.instance.write(chunk) }
252
+ end
253
+
254
+ test 'write handles chunk with nil tag' do
255
+ ingester_mock = ingester_stub
256
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once
257
+ logger_mock = logger_stub
258
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
259
+ chunk = chunk_stub(tag: nil)
260
+ assert_nothing_raised { @driver.instance.write(chunk) }
261
+ end
262
+
263
+ test 'write handles chunk with very large data' do
264
+ ingester_mock = ingester_stub
265
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once
266
+ logger_mock = logger_stub
267
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
268
+ chunk = chunk_stub(data: 'x' * 10_000_000)
269
+ assert_nothing_raised { @driver.instance.write(chunk) }
270
+ end
271
+
272
+ test 'write handles logger that only responds to error' do
273
+ ingester_mock = mock
274
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once
275
+ logger_mock = mock
276
+ def logger_mock.debug(*)
277
+ raise NoMethodError
278
+ end
279
+ logger_mock.stubs(:error)
280
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
281
+ chunk = chunk_stub
282
+ assert_nothing_raised { @driver.instance.write(chunk) }
283
+ end
284
+
285
+ test 'write handles logger.error raising exception' do
286
+ ingester_mock = mock
287
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once
288
+ logger_mock = mock
289
+ logger_mock.stubs(:debug)
290
+ def logger_mock.error(*)
291
+ raise 'logger error'
292
+ end
293
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
294
+ chunk = chunk_stub
295
+ assert_nothing_raised { @driver.instance.write(chunk) }
296
+ end
297
+
298
+ test 'write is thread safe with concurrent calls' do
299
+ set_mocks(ingester: ingester_stub, logger: logger_stub)
300
+ chunk = chunk_stub
301
+ threads = 5.times.map do
302
+ Thread.new { assert_nothing_raised { @driver.instance.write(chunk) } }
303
+ end
304
+ threads.each(&:join)
305
+ end
306
+
307
+ test 'write handles logger with info but not error' do
308
+ ingester_mock = mock
309
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once
310
+ logger_mock = mock
311
+ logger_mock.stubs(:info)
312
+ def logger_mock.error(*)
313
+ raise NoMethodError
314
+ end
315
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
316
+ chunk = chunk_stub
317
+ assert_nothing_raised { @driver.instance.write(chunk) }
318
+ end
319
+
320
+ test 'write handles chunk.metadata without tag method' do
321
+ ingester_mock = mock
322
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once
323
+ logger_mock = mock
324
+ logger_mock.stubs(:info)
325
+ logger_mock.stubs(:error)
326
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
327
+ metadata_obj = Object.new
328
+ chunk = mock
329
+ chunk.stubs(:read).returns('testdata')
330
+ chunk.stubs(:metadata).returns(metadata_obj)
331
+ chunk.stubs(:unique_id).returns('uniqueid'.b)
332
+ assert_nothing_raised { @driver.instance.write(chunk) }
333
+ end
334
+
335
+ test 'write handles unique_id as integer' do
336
+ ingester_mock = ingester_stub
337
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once
338
+ logger_mock = logger_stub
339
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
340
+ chunk = chunk_stub(unique_id: 12_345)
341
+ assert_nothing_raised { @driver.instance.write(chunk) }
342
+ end
343
+
344
+ test 'write handles unique_id as array' do
345
+ ingester_mock = ingester_stub
346
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once
347
+ logger_mock = logger_stub
348
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
349
+ chunk = chunk_stub(unique_id: [1, 2, 3])
350
+ assert_nothing_raised { @driver.instance.write(chunk) }
351
+ end
352
+
353
+ test 'write handles chunk.read returning nil' do
354
+ ingester_mock = ingester_stub
355
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once
356
+ logger_mock = logger_stub
357
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
358
+ chunk = chunk_stub(data: nil)
359
+ assert_nothing_raised { @driver.instance.write(chunk) }
360
+ end
361
+
362
+ test 'write handles tag and unique_id with unicode and invalid bytes' do
363
+ ingester_mock = ingester_stub
364
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once
365
+ logger_mock = logger_stub
366
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
367
+ chunk = chunk_stub(tag: "t\u2603\xFF", unique_id: "\xFF\xFE\xFD".b)
368
+ assert_nothing_raised { @driver.instance.write(chunk) }
369
+ end
370
+ end
metadata ADDED
@@ -0,0 +1,171 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fluent-plugin-kusto
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1.beta
5
+ platform: ruby
6
+ authors:
7
+ - Komal Rani
8
+ - Kusto OSS IDC Team
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2025-08-20 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: bundler
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: 2.6.9
21
+ type: :development
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - "~>"
26
+ - !ruby/object:Gem::Version
27
+ version: 2.6.9
28
+ - !ruby/object:Gem::Dependency
29
+ name: rake
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - "~>"
33
+ - !ruby/object:Gem::Version
34
+ version: 13.2.1
35
+ type: :development
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - "~>"
40
+ - !ruby/object:Gem::Version
41
+ version: 13.2.1
42
+ - !ruby/object:Gem::Dependency
43
+ name: test-unit
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - "~>"
47
+ - !ruby/object:Gem::Version
48
+ version: 3.6.7
49
+ type: :development
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - "~>"
54
+ - !ruby/object:Gem::Version
55
+ version: 3.6.7
56
+ - !ruby/object:Gem::Dependency
57
+ name: fluentd
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '1.0'
63
+ - - "<"
64
+ - !ruby/object:Gem::Version
65
+ version: '2'
66
+ type: :runtime
67
+ prerelease: false
68
+ version_requirements: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '1.0'
73
+ - - "<"
74
+ - !ruby/object:Gem::Version
75
+ version: '2'
76
+ - !ruby/object:Gem::Dependency
77
+ name: rubocop
78
+ requirement: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.0'
83
+ type: :development
84
+ prerelease: false
85
+ version_requirements: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.0'
90
+ - !ruby/object:Gem::Dependency
91
+ name: dotenv
92
+ requirement: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '2.0'
97
+ type: :runtime
98
+ prerelease: false
99
+ version_requirements: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '2.0'
104
+ description: Fluentd output plugin to ingest logs into Azure Data Explorer (Kusto),
105
+ supporting managed identity and AAD authentication, with multi-worker and buffer
106
+ support.
107
+ email:
108
+ - t-komalrani+microsoft@microsoft.com
109
+ - kustoossidc@microsoft.com
110
+ executables: []
111
+ extensions: []
112
+ extra_rdoc_files: []
113
+ files:
114
+ - Gemfile
115
+ - LICENSE
116
+ - README.md
117
+ - lib/fluent/plugin/auth/aad_tokenprovider.rb
118
+ - lib/fluent/plugin/auth/azcli_tokenprovider.rb
119
+ - lib/fluent/plugin/auth/mi_tokenprovider.rb
120
+ - lib/fluent/plugin/auth/tokenprovider_base.rb
121
+ - lib/fluent/plugin/auth/wif_tokenprovider.rb
122
+ - lib/fluent/plugin/client.rb
123
+ - lib/fluent/plugin/conffile.rb
124
+ - lib/fluent/plugin/ingester.rb
125
+ - lib/fluent/plugin/kusto_error_handler.rb
126
+ - lib/fluent/plugin/kusto_query.rb
127
+ - lib/fluent/plugin/out_kusto.rb
128
+ - test/helper.rb
129
+ - test/plugin/test_azcli_tokenprovider.rb
130
+ - test/plugin/test_e2e_kusto.rb
131
+ - test/plugin/test_out_kusto_config.rb
132
+ - test/plugin/test_out_kusto_format.rb
133
+ - test/plugin/test_out_kusto_process.rb
134
+ - test/plugin/test_out_kusto_start.rb
135
+ - test/plugin/test_out_kusto_try_write.rb
136
+ - test/plugin/test_out_kusto_write.rb
137
+ homepage: https://github.com/Azure/azure-kusto-fluentd
138
+ licenses:
139
+ - Apache-2.0
140
+ metadata:
141
+ fluentd_plugin: 'true'
142
+ fluentd_group: output
143
+ post_install_message:
144
+ rdoc_options: []
145
+ require_paths:
146
+ - lib
147
+ required_ruby_version: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '2.5'
152
+ required_rubygems_version: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - ">"
155
+ - !ruby/object:Gem::Version
156
+ version: 1.3.1
157
+ requirements: []
158
+ rubygems_version: 3.0.3.1
159
+ signing_key:
160
+ specification_version: 4
161
+ summary: A custom Fluentd output plugin for Azure Kusto ingestion.
162
+ test_files:
163
+ - test/helper.rb
164
+ - test/plugin/test_azcli_tokenprovider.rb
165
+ - test/plugin/test_e2e_kusto.rb
166
+ - test/plugin/test_out_kusto_config.rb
167
+ - test/plugin/test_out_kusto_format.rb
168
+ - test/plugin/test_out_kusto_process.rb
169
+ - test/plugin/test_out_kusto_start.rb
170
+ - test/plugin/test_out_kusto_try_write.rb
171
+ - test/plugin/test_out_kusto_write.rb