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,86 @@
1
+ # rubocop:disable all
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../helper'
5
+ require 'fluent/plugin/out_kusto'
6
+ require 'fluent/plugin/conffile'
7
+
8
+ class KustoOutputConfigTest < Test::Unit::TestCase
9
+ def setup
10
+ Fluent::Test.setup
11
+ @base_conf = {
12
+ 'kusto_endpoint' => 'https://example.kusto.windows.net',
13
+ 'database_name' => 'testdb',
14
+ 'table_name' => 'testtable',
15
+ 'buffered' => 'true'
16
+ }
17
+ end
18
+
19
+ test 'plugin raises error if buffer section present but buffered is false' do
20
+ conf = "#{config_str(@base_conf.merge('buffered' => 'false'))}\n<buffer>\n @type memory\n</buffer>\n"
21
+ assert_raise(Fluent::ConfigError) { create_driver(conf) }
22
+ end
23
+
24
+ test 'plugin raises error when required param is empty string' do
25
+ %w[kusto_endpoint database_name table_name].each do |param|
26
+ conf_hash = @base_conf.dup
27
+ conf_hash[param] = ''
28
+ conf = config_str(conf_hash)
29
+ assert_raise(Fluent::ConfigError, "Should fail when #{param} is empty") { create_driver(conf) }
30
+ end
31
+ end
32
+
33
+ test 'plugin raises error when required param is whitespace only' do
34
+ %w[kusto_endpoint database_name table_name].each do |param|
35
+ conf_hash = @base_conf.dup
36
+ conf_hash[param] = ' '
37
+ conf = config_str(conf_hash)
38
+ assert_raise(Fluent::ConfigError, "Should fail when #{param} is whitespace only") { create_driver(conf) }
39
+ end
40
+ end
41
+
42
+ test 'plugin raises error when azure_cloud is invalid' do
43
+ assert_raise(ArgumentError) { OutputConfiguration.new(@base_conf.merge('azure_cloud' => 'InvalidCloud').transform_keys(&:to_sym)).validate_configuration }
44
+ end
45
+
46
+ test 'plugin accepts valid managed_identity_client_id' do
47
+ conf = config_str(
48
+ @base_conf
49
+ .merge('auth_type' => 'user_managed_identity')
50
+ .merge('managed_identity_client_id' => 'valid_id')
51
+ .merge('endpoint' => @base_conf['kusto_endpoint'])
52
+ .merge('auth_type' => 'user_managed_identity') # Ensure required field is present
53
+ )
54
+ assert_nothing_raised { create_driver(conf) }
55
+ end
56
+
57
+ test 'plugin sets aad_endpoint based on azure_cloud' do
58
+ clouds = {
59
+ 'AzureCloud' => 'https://login.microsoftonline.com',
60
+ 'AzureChinaCloud' => 'https://login.chinacloudapi.cn',
61
+ 'AzureUSGovernment' => 'https://login.microsoftonline.us'
62
+ }
63
+ base_conf_sym = @base_conf.transform_keys(&:to_sym)
64
+ clouds.each do |cloud, aad_url|
65
+ config = OutputConfiguration.new(
66
+ base_conf_sym.merge(
67
+ azure_cloud: cloud,
68
+ client_app_id: 'dummy-client-id',
69
+ client_app_secret: 'dummy-secret',
70
+ tenant_id: 'dummy-tenant'
71
+ )
72
+ )
73
+ assert_equal(aad_url, config.aad_endpoint)
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def config_str(hash)
80
+ hash.map { |k, v| "#{k} #{v}" }.unshift('@type kusto').join("\n")
81
+ end
82
+
83
+ def create_driver(conf)
84
+ Fluent::Test::Driver::Output.new(Fluent::Plugin::KustoOutput).configure(conf)
85
+ end
86
+ end
@@ -0,0 +1,280 @@
1
+ # rubocop:disable all
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../helper'
5
+ require 'fluent/plugin/out_kusto'
6
+
7
+ class KustoOutputFormatTest < Test::Unit::TestCase
8
+ setup do
9
+ Fluent::Test.setup
10
+ @driver = Fluent::Test::Driver::Output.new(Fluent::Plugin::KustoOutput).configure(<<-CONF)
11
+ @type kusto
12
+ endpoint https://example.kusto.windows.net
13
+ database_name testdb
14
+ table_name testtable
15
+ client_id dummy-client-id
16
+ client_secret dummy-secret
17
+ tenant_id dummy-tenant
18
+ buffered true
19
+ auth_type aad
20
+ CONF
21
+ end
22
+
23
+ test 'format includes tag, timestamp, and record fields' do
24
+ tag = 'mytag'
25
+ time = Time.utc(2024, 1, 1, 12, 0, 0).to_i
26
+ record = { 'foo' => 'bar', 'baz' => 1 }
27
+ json = @driver.instance.format(tag, time, record)
28
+ parsed = JSON.parse(json)
29
+ assert_equal 'mytag', parsed['tag']
30
+ assert_equal Time.at(time).utc.iso8601, parsed['timestamp']
31
+ assert_equal({ 'foo' => 'bar', 'baz' => 1 }, parsed['record'])
32
+ end
33
+
34
+ test "format prefers record['tag'] over argument tag" do
35
+ tag = 'outertag'
36
+ time = Time.now.to_i
37
+ record = { 'tag' => 'innertag', 'foo' => 'bar' }
38
+ json = @driver.instance.format(tag, time, record)
39
+ parsed = JSON.parse(json)
40
+ assert_equal 'innertag', parsed['tag']
41
+ end
42
+
43
+ test "format uses record['tag'] over all fallbacks" do
44
+ tag = 'outer'
45
+ time = 1
46
+ record = { 'tag' => 'inner', 'host' => 'hostval', 'user' => 'userval', 'message' => '1.2.3.4' }
47
+ json = @driver.instance.format(tag, time, record)
48
+ parsed = JSON.parse(json)
49
+ assert_equal 'inner', parsed['tag']
50
+ end
51
+
52
+ test "format uses record['time'] over record['timestamp'] and time param" do
53
+ tag = 't'
54
+ time = 123
55
+ record = { 'time' => 't1', 'timestamp' => 't2' }
56
+ json = @driver.instance.format(tag, time, record)
57
+ parsed = JSON.parse(json)
58
+ assert_equal 't1', parsed['timestamp']
59
+ end
60
+
61
+ test "format uses record['timestamp'] over time param" do
62
+ tag = 't'
63
+ time = 123
64
+ record = { 'timestamp' => 't2' }
65
+ json = @driver.instance.format(tag, time, record)
66
+ parsed = JSON.parse(json)
67
+ assert_equal 't2', parsed['timestamp']
68
+ end
69
+
70
+ test 'format falls back to default_tag if no tag or fallback fields' do
71
+ tag = nil
72
+ time = nil
73
+ record = { 'foo' => 'bar' }
74
+ json = @driver.instance.format(tag, time, record)
75
+ parsed = JSON.parse(json)
76
+ assert_equal 'default_tag', parsed['tag']
77
+ end
78
+
79
+ test 'format removes tag and time from record in output' do
80
+ tag = 't'
81
+ time = 123
82
+ record = { 'foo' => 1, 'tag' => 't', 'time' => 'sometime' }
83
+ json = @driver.instance.format(tag, time, record)
84
+ parsed = JSON.parse(json)
85
+ assert_not_include parsed['record'].keys, 'tag'
86
+ assert_not_include parsed['record'].keys, 'time'
87
+ end
88
+
89
+ test 'format falls back to host, user, or IP in message if tag is missing' do
90
+ tag = nil
91
+ time = 0
92
+ record = { 'host' => 'hostval', 'foo' => 1 }
93
+ json = @driver.instance.format(tag, time, record)
94
+ parsed = JSON.parse(json)
95
+ assert_equal 'hostval', parsed['tag']
96
+ record2 = { 'user' => 'userval', 'foo' => 2 }
97
+ json2 = @driver.instance.format(tag, time, record2)
98
+ parsed2 = JSON.parse(json2)
99
+ assert_equal 'userval', parsed2['tag']
100
+ record3 = { 'message' => '192.168.1.1 something happened', 'foo' => 3 }
101
+ json3 = @driver.instance.format(tag, time, record3)
102
+ parsed3 = JSON.parse(json3)
103
+ assert_equal '192.168.1.1', parsed3['tag']
104
+ end
105
+
106
+ test 'format falls back to date/time in log message if timestamp missing' do
107
+ tag = 't'
108
+ time = nil
109
+ record = { 'foo' => 'bar', 'msg' => '[01/Jan/2024:12:00:00 +0000] log entry' }
110
+ json = @driver.instance.format(tag, time, record)
111
+ parsed = JSON.parse(json)
112
+ assert_equal '[01/Jan/2024:12:00:00 +0000] log entry', parsed['timestamp']
113
+ end
114
+
115
+ test 'format sets timestamp to empty string if no time info' do
116
+ tag = 't'
117
+ time = nil
118
+ record = { 'foo' => 'bar' }
119
+ json = @driver.instance.format(tag, time, record)
120
+ parsed = JSON.parse(json)
121
+ assert_equal '', parsed['timestamp']
122
+ end
123
+
124
+ test 'format uses first date/time pattern found in record' do
125
+ tag = 't'
126
+ time = nil
127
+ record = {
128
+ 'foo' => 'no date here',
129
+ 'bar' => '[01/Jan/2024:12:00:00 +0000] log entry',
130
+ 'baz' => '[02/Feb/2025:13:00:00 +0000] another'
131
+ }
132
+ json = @driver.instance.format(tag, time, record)
133
+ parsed = JSON.parse(json)
134
+ assert_equal '[01/Jan/2024:12:00:00 +0000] log entry', parsed['timestamp']
135
+ end
136
+
137
+ test 'format output is valid JSON and ends with newline' do
138
+ tag = 't'
139
+ time = 1
140
+ record = { 'foo' => 'bar' }
141
+ json = @driver.instance.format(tag, time, record)
142
+ assert_nothing_raised { JSON.parse(json) }
143
+ assert_match(/\n\z/, json)
144
+ end
145
+
146
+ test 'format raises error on circular reference' do
147
+ tag = 't'
148
+ time = 1
149
+ record = {}
150
+ record['self'] = record
151
+ assert_raise_with_message(RuntimeError, 'Circular reference detected in record') do
152
+ @driver.instance.format(tag, time, record)
153
+ end
154
+ end
155
+
156
+ test "format uses any key containing 'time' or 'date' for timestamp" do
157
+ tag = 't'
158
+ time = 123
159
+ record = { 'event_time' => '2024-01-01T12:00:00Z', 'foo' => 1 }
160
+ json = @driver.instance.format(tag, time, record)
161
+ parsed = JSON.parse(json)
162
+ assert_equal '2024-01-01T12:00:00Z', parsed['timestamp']
163
+
164
+ record2 = { 'logdate' => '2023-12-31', 'foo' => 2 }
165
+ json2 = @driver.instance.format(tag, time, record2)
166
+ parsed2 = JSON.parse(json2)
167
+ assert_equal '2023-12-31', parsed2['timestamp']
168
+
169
+ record3 = { 'sometime' => '', 'foo' => 3 }
170
+ json3 = @driver.instance.format(tag, time, record3)
171
+ parsed3 = JSON.parse(json3)
172
+ # Falls back to time param if key exists but is empty
173
+ assert_equal Time.at(time).utc.iso8601, parsed3['timestamp']
174
+ end
175
+
176
+ test 'format handles record with JSON string value' do
177
+ tag = 't'
178
+ time = 1
179
+ json_str = '{"foo": "bar", "baz": 1}'
180
+ record = { 'data' => json_str }
181
+ json = @driver.instance.format(tag, time, record)
182
+ parsed = JSON.parse(json)
183
+ assert_equal json_str, parsed['record']['data']
184
+ end
185
+
186
+ test 'format handles record with CSV string value' do
187
+ tag = 't'
188
+ time = 1
189
+ csv_str = "foo,bar,baz\n1,2,3"
190
+ record = { 'csv_data' => csv_str }
191
+ json = @driver.instance.format(tag, time, record)
192
+ parsed = JSON.parse(json)
193
+ assert_equal csv_str, parsed['record']['csv_data']
194
+ end
195
+
196
+ test 'format handles record with nested hash (parsed JSON)' do
197
+ tag = 't'
198
+ time = 1
199
+ nested = { 'foo' => 'bar', 'baz' => 1 }
200
+ record = { 'nested' => nested }
201
+ json = @driver.instance.format(tag, time, record)
202
+ parsed = JSON.parse(json)
203
+ assert_equal nested, parsed['record']['nested']
204
+ end
205
+
206
+ test 'format handles nil record gracefully' do
207
+ tag = 't'
208
+ time = 1
209
+ record = nil
210
+ json = @driver.instance.format(tag, time, record)
211
+ parsed = JSON.parse(json)
212
+ assert_equal 't', parsed['tag']
213
+ assert_equal Time.at(time).utc.iso8601, parsed['timestamp']
214
+ assert_equal({}, parsed['record'])
215
+ end
216
+
217
+ test 'format handles array record' do
218
+ tag = 't'
219
+ time = 1
220
+ record = [1, 2, 3]
221
+ json = @driver.instance.format(tag, time, record)
222
+ parsed = JSON.parse(json)
223
+ assert_equal 't', parsed['tag']
224
+ assert_equal Time.at(time).utc.iso8601, parsed['timestamp']
225
+ assert_equal [1, 2, 3], parsed['record']
226
+ end
227
+
228
+ test 'format handles deeply nested hash and array' do
229
+ tag = 't'
230
+ time = 1
231
+ record = { 'a' => { 'b' => [1, { 'c' => 2 }] } }
232
+ json = @driver.instance.format(tag, time, record)
233
+ parsed = JSON.parse(json)
234
+ assert_equal({ 'a' => { 'b' => [1, { 'c' => 2 }] } }, parsed['record'])
235
+ end
236
+
237
+ test 'format handles special characters in keys and values' do
238
+ tag = 't'
239
+ time = 1
240
+ record = { "uni\u2603" => "snowman\u2603", "newline" => "line1\nline2" }
241
+ json = @driver.instance.format(tag, time, record)
242
+ parsed = JSON.parse(json)
243
+ assert_equal "snowman\u2603", parsed['record']["uni\u2603"]
244
+ assert_equal "line1\nline2", parsed['record']['newline']
245
+ end
246
+
247
+ test 'format handles very large record' do
248
+ tag = 't'
249
+ time = 1
250
+ record = {}
251
+ 1000.times { |i| record["key#{i}"] = i }
252
+ json = @driver.instance.format(tag, time, record)
253
+ parsed = JSON.parse(json)
254
+ assert_equal 1000, parsed['record'].size
255
+ end
256
+
257
+ test 'format handles boolean, nil, and float values' do
258
+ tag = 't'
259
+ time = 1
260
+ record = { 'bool' => true, 'nilval' => nil, 'float' => 1.23 }
261
+ json = @driver.instance.format(tag, time, record)
262
+ parsed = JSON.parse(json)
263
+ assert_equal true, parsed['record']['bool']
264
+ assert_nil parsed['record']['nilval']
265
+ assert_in_delta 1.23, parsed['record']['float'], 0.0001
266
+ end
267
+
268
+ test 'format handles empty hash and array' do
269
+ tag = 't'
270
+ time = 1
271
+ record = {}
272
+ json = @driver.instance.format(tag, time, record)
273
+ parsed = JSON.parse(json)
274
+ assert_equal({}, parsed['record'])
275
+ record2 = []
276
+ json2 = @driver.instance.format(tag, time, record2)
277
+ parsed2 = JSON.parse(json2)
278
+ assert_equal [], parsed2['record']
279
+ end
280
+ end
@@ -0,0 +1,150 @@
1
+ # rubocop:disable all
2
+ require_relative "../helper"
3
+ require "fluent/test/driver/output"
4
+ require "fluent/plugin/out_kusto.rb"
5
+ require "mocha/test_unit"
6
+
7
+ class KustoOutputProcessTest < Test::Unit::TestCase
8
+ setup do
9
+ Fluent::Test.setup
10
+ @driver = Fluent::Test::Driver::Output.new(Fluent::Plugin::KustoOutput).configure(<<-CONF)
11
+ @type kusto
12
+ endpoint https://example.kusto.windows.net
13
+ database_name testdb
14
+ table_name testtable
15
+ client_id dummy-client-id
16
+ client_secret dummy-secret
17
+ tenant_id dummy-tenant
18
+ buffered true
19
+ auth_type aad
20
+ CONF
21
+ end
22
+
23
+ def set_mocks(ingester: nil, logger: nil)
24
+ @driver.instance.instance_variable_set(:@ingester, ingester) if ingester
25
+ @driver.instance.instance_variable_set(:@logger, logger) if logger
26
+ end
27
+
28
+ def event_stream(arr)
29
+ Fluent::ArrayEventStream.new(arr)
30
+ end
31
+
32
+ def logger_stub
33
+ m = mock
34
+ m.stubs(:debug)
35
+ m.stubs(:error)
36
+ m.stubs(:warn)
37
+ m.stubs(:info)
38
+ m
39
+ end
40
+
41
+ def ingester_stub
42
+ m = mock
43
+ m.stubs(:upload_data_to_blob_and_queue)
44
+ m
45
+ end
46
+
47
+ test "process logs error but continues on upload failure" do
48
+ ingester_mock = mock; ingester_mock.stubs(:upload_data_to_blob_and_queue).raises(StandardError, "upload failed")
49
+ logger_mock = mock; logger_mock.stubs(:debug); logger_mock.expects(:error).at_least_once
50
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
51
+ assert_nothing_raised { @driver.instance.process("test.tag", event_stream([[Time.now.to_i, {"foo" => "bar"}]])) }
52
+ end
53
+
54
+ test "process handles empty event stream" do
55
+ set_mocks(ingester: ingester_stub, logger: logger_stub)
56
+ assert_nothing_raised { @driver.instance.process("test.tag", event_stream([])) }
57
+ end
58
+
59
+ test "process handles non-hash record" do
60
+ set_mocks(ingester: ingester_stub, logger: logger_stub)
61
+ assert_nothing_raised { @driver.instance.process("test.tag", event_stream([[Time.now.to_i, "not a hash"]])) }
62
+ end
63
+
64
+ test "process handles nil record" do
65
+ set_mocks(ingester: ingester_stub, logger: logger_stub)
66
+ assert_nothing_raised { @driver.instance.process("test.tag", event_stream([[Time.now.to_i, nil]])) }
67
+ end
68
+
69
+ test "process handles format raising error (circular reference)" do
70
+ set_mocks(ingester: ingester_stub, logger: logger_stub)
71
+ record = {}; record["self"] = record
72
+ assert_nothing_raised { @driver.instance.process("test.tag", event_stream([[Time.now.to_i, record]])) }
73
+ end
74
+
75
+ test "process handles upload raising different errors" do
76
+ ingester_mock = mock; ingester_mock.stubs(:upload_data_to_blob_and_queue).raises(IOError, "io error")
77
+ logger_mock = logger_stub; logger_mock.expects(:error).at_least_once
78
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
79
+ assert_nothing_raised { @driver.instance.process("test.tag", event_stream([[Time.now.to_i, {"foo" => "bar"}]])) }
80
+ end
81
+
82
+ test "process continues after multiple failures" do
83
+ ingester_mock = mock; ingester_mock.stubs(:upload_data_to_blob_and_queue).raises(StandardError, "fail")
84
+ logger_mock = logger_stub; logger_mock.expects(:error).at_least_once
85
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
86
+ assert_nothing_raised { @driver.instance.process("test.tag", event_stream([[Time.now.to_i, {"foo" => "bar"}], [Time.now.to_i, {"foo" => "baz"}]])) }
87
+ end
88
+
89
+ test "process handles logger being nil" do
90
+ set_mocks(ingester: ingester_stub, logger: nil)
91
+ assert_nothing_raised { @driver.instance.process("test.tag", event_stream([[Time.now.to_i, {"foo" => "bar"}]])) }
92
+ end
93
+
94
+ test "process handles empty string time" do
95
+ set_mocks(ingester: ingester_stub, logger: logger_stub)
96
+ assert_nothing_raised { @driver.instance.process("test.tag", event_stream([["", {"foo" => "bar"}]])) }
97
+ end
98
+
99
+ test "process handles array as record" do
100
+ set_mocks(ingester: ingester_stub, logger: logger_stub)
101
+ assert_nothing_raised { @driver.instance.process("test.tag", event_stream([[Time.now.to_i, [1,2,3]]])) }
102
+ end
103
+
104
+ test "process handles very large record" do
105
+ set_mocks(ingester: ingester_stub, logger: logger_stub)
106
+ big_record = {"foo" => "x" * 100_000}
107
+ assert_nothing_raised { @driver.instance.process("test.tag", event_stream([[Time.now.to_i, big_record]])) }
108
+ end
109
+
110
+ test "process handles deeply nested record" do
111
+ set_mocks(ingester: ingester_stub, logger: logger_stub)
112
+ nested = {"a"=>{"b"=>{"c"=>{"d"=>1}}}}
113
+ assert_nothing_raised { @driver.instance.process("test.tag", event_stream([[Time.now.to_i, nested]])) }
114
+ end
115
+
116
+ test "process handles binary data in record" do
117
+ logger_mock = mock; logger_mock.stubs(:debug); logger_mock.stubs(:error); logger_mock.stubs(:warn)
118
+ set_mocks(ingester: mock, logger: logger_mock)
119
+ record = {"bin" => "\xFF".b}
120
+ assert_nothing_raised { @driver.instance.process("test.tag", event_stream([[Time.now.to_i, record]])) }
121
+ end
122
+
123
+ test "process encodes formatted data to UTF-8 and replaces invalid characters" do
124
+ ingester_mock = mock
125
+ ingester_mock.stubs(:upload_data_to_blob_and_queue)
126
+ logger_mock = logger_stub
127
+ logger_mock.stubs(:error)
128
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
129
+ bad_utf8 = "foo\xFFbar".force_encoding("ASCII-8BIT")
130
+ assert_nothing_raised { @driver.instance.process("test.tag", event_stream([[Time.now.to_i, {"foo" => bad_utf8}]]) ) }
131
+ end
132
+
133
+ test "process handles event stream with mixed valid and invalid events" do
134
+ logger_mock = logger_stub
135
+ logger_mock.stubs(:error)
136
+ set_mocks(ingester: ingester_stub, logger: logger_mock)
137
+ es = event_stream([[Time.now.to_i, {"foo" => "bar"}], [Time.now.to_i, nil], [Time.now.to_i, "not a hash"]])
138
+ assert_nothing_raised { @driver.instance.process("test.tag", es) }
139
+ end
140
+
141
+ test "process logs error with correct message on upload failure" do
142
+ ingester_mock = mock
143
+ ingester_mock.stubs(:upload_data_to_blob_and_queue).raises(StandardError, "upload failed")
144
+ logger_mock = mock
145
+ logger_mock.stubs(:debug)
146
+ logger_mock.expects(:error).with { |msg| msg.include?("Failed to ingest event to Kusto") && msg.include?("upload failed") }
147
+ set_mocks(ingester: ingester_mock, logger: logger_mock)
148
+ assert_nothing_raised { @driver.instance.process("test.tag", event_stream([[Time.now.to_i, {"foo" => "bar"}]])) }
149
+ end
150
+ end