fluent-plugin-kusto 1.0.0 → 1.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ba5fad040c3a296f33314e11714338aaa50e9a28874aac512439f80efde8463
4
- data.tar.gz: 0e51820d8e83c1d4e5e3dddb2b85f20eb0b662b8aa27343c75166ad3e56cf33a
3
+ metadata.gz: 3f5182c8d42b84b2e41dfb92f5bd01be66e3d2ebefb6171182c16b464d4af27e
4
+ data.tar.gz: 81ec9a5a9ae6d3d41daa76c1334104e6c7987655e6c81df158081233394b4340
5
5
  SHA512:
6
- metadata.gz: 2fc98cdf258348febc73ad4eb67c2ca52dfde4a38a15bfd9896096fd40e465edaaa9b1545b8ac07c8c781b4621edd1a4581133c06ecd1c703847692a55fdc720
7
- data.tar.gz: 8e4d1fecd4929047f04e1c2f1034b7ab614c781cfd1a3e8268415e4e31578ccdc0fdd62e9fedafc9b1f5c180d9963099da1faaf251c470aa683472dc7d725b6b
6
+ metadata.gz: f3e7620f5ec7327efb52897436c4f7a40bb0f0a7d9ac3ee7f0f5f39eccf9260c03e0d6b7211f610e7b85eb9de91ffff05f16008c3636f11ef1f135857b8d72b5
7
+ data.tar.gz: 2cef3305e66b2062f2d5be1b1fac4692ddf8a550885ab9a04c77bc9f861f56e45f74ce3232a0c6860d88fa1c0817234493a1ce3b813af891291835d07eff20cf
data/README.md CHANGED
@@ -42,7 +42,7 @@ This plugin allows you to send data from Fluentd to Azure Data Explorer (Kusto)
42
42
  ### RubyGems
43
43
 
44
44
  ```sh
45
- $ gem install fluent-plugin-kusto --pre
45
+ $ gem install fluent-plugin-kusto
46
46
  ```
47
47
 
48
48
  ### Bundler
@@ -50,11 +50,9 @@ $ gem install fluent-plugin-kusto --pre
50
50
  Add the following line to your Gemfile:
51
51
 
52
52
  ```ruby
53
- gem "fluent-plugin-kusto", "~> 0.0.2.beta"
53
+ gem "fluent-plugin-kusto", "~> 1.1.1.beta"
54
54
  ```
55
55
 
56
- **Note:** This is a beta release. Use the `--pre` flag with gem install or specify the beta version in your Gemfile.
57
-
58
56
  And then execute:
59
57
 
60
58
  ```sh
@@ -244,6 +242,60 @@ This approach provides flexibility to transform the generic 3-column format into
244
242
  | `azure_cloud` | Azure cloud environment: `AzureCloud`, `AzureChinaCloud`, `AzureUSGovernmentCloud`, `AzureGermanCloud` | `AzureCloud` |
245
243
  | `logger_path` | File path for plugin logs. If not set, logs to stdout. | stdout |
246
244
 
245
+ ## Dynamic Table Name Resolution
246
+
247
+ The plugin supports dynamic table name resolution using placeholders in the `table_name` parameter. This allows you to route logs to different tables based on the Fluentd tag.
248
+
249
+ ### Supported Placeholders
250
+
251
+ | Placeholder | Description | Example |
252
+ |------------|-------------|---------|
253
+ | `${tag}` | Full tag name (dots → underscores) | `app.orders.created` → `app_orders_created` |
254
+ | `${tag_parts[N]}` | Nth part of tag (0-indexed) | `${tag_parts[1]}` with `app.orders.created` → `orders` |
255
+ | `${tag_prefix[N]}` | First N parts joined | `${tag_prefix[2]}` with `app.orders.created` → `app_orders` |
256
+ | `${tag_suffix[N]}` | Last N parts joined | `${tag_suffix[2]}` with `app.orders.created` → `orders_created` |
257
+
258
+ ### Usage Examples
259
+
260
+ **Route by tag part:**
261
+ ```conf
262
+ <match custom.**>
263
+ @type kusto
264
+ table_name ${tag_parts[1]}
265
+ # custom.orders.created → orders table
266
+ # custom.users.signup → users table
267
+ </match>
268
+ ```
269
+
270
+ **Mixed static and dynamic:**
271
+ ```conf
272
+ <match app.**>
273
+ @type kusto
274
+ table_name logs_${tag_parts[1]}
275
+ # app.orders → logs_orders table
276
+ </match>
277
+ ```
278
+
279
+ **Multiple placeholders:**
280
+ ```conf
281
+ <match **>
282
+ @type kusto
283
+ table_name ${tag_parts[0]}_${tag_parts[1]}_events
284
+ # production.api.requests → production_api_events table
285
+ </match>
286
+ ```
287
+
288
+ **Notes:**
289
+ - All special characters are automatically converted to underscores
290
+ - Consecutive underscores are collapsed to single underscores
291
+ - Static table names (without placeholders) continue to work as before
292
+ - Placeholders are resolved at ingestion time based on the event tag
293
+
294
+ **Important Limitations:**
295
+ - ⚠️ **Delayed commits not supported with dynamic table names**: The `delayed` commit feature is currently incompatible with placeholder-based table names. When using dynamic table names, ensure `delayed` is set to `false` (the default).
296
+ - ⚠️ **Validate placeholder patterns**: Ensure your placeholder patterns always resolve to non-empty, valid table names for all expected tags. For example, accessing a tag part index that doesn't exist (e.g., `${tag_parts[5]}` for tag `app.orders`) will resolve to `"unknown"` as a fallback.
297
+ - ⚠️ **Empty or nil tags**: If a tag is empty or nil when using placeholders, the plugin will use `"unknown"` as a fallback to prevent ingestion failures.
298
+
247
299
  ### Buffer Configuration (buffered mode only)
248
300
  | Key | Description | Default |
249
301
  | --- | ----------- | ------- |
@@ -393,7 +445,13 @@ This diagram shows the main components and data flow for the plugin, including c
393
445
 
394
446
  ## Release Notes
395
447
 
396
- ### v0.0.2.beta (Latest)
448
+ ### v1.1.1.beta (Latest)
449
+ - **Dynamic table name resolution** - Added support for placeholder-based table name routing using `${tag}`, `${tag_parts[N]}`, `${tag_prefix[N]}`, and `${tag_suffix[N]}`
450
+ - **Enhanced flexibility** - Route logs to different tables based on Fluentd tags without code changes
451
+ - **Backwards compatible** - Static table names continue to work as before
452
+
453
+ ### v1.0.0
454
+ - **Production-ready release** - Stable version with comprehensive testing
397
455
  - **Fixed critical authentication initialization bugs** - Resolved `NameError` in ManagedIdentityTokenProvider and WorkloadIdentityTokenProvider
398
456
  - **Added comprehensive unit test coverage** - New test suites for authentication providers with 14 test cases and 45+ assertions
399
457
  - **Improved E2E test reliability** - Enhanced timeout configurations to handle Azure Kusto ingestion delays (480s-600s timeouts)
@@ -3,7 +3,7 @@
3
3
  module Fluent
4
4
  module Plugin
5
5
  module Kusto
6
- VERSION = '1.0.0'
6
+ VERSION = '1.1.1.beta'
7
7
  end
8
8
  end
9
9
  end
@@ -63,6 +63,7 @@ module Fluent
63
63
  validate_buffer_config(conf)
64
64
  validate_delayed_config
65
65
  validate_required_params
66
+ @table_name_template = table_name
66
67
  end
67
68
 
68
69
  def start
@@ -70,7 +71,7 @@ module Fluent
70
71
  super
71
72
  setup_outconfiguration
72
73
  setup_ingester_and_logger
73
- @table_name = @outconfiguration&.table_name
74
+ @table_name_template = @outconfiguration&.table_name
74
75
  @database_name = @outconfiguration&.database_name
75
76
  @shutdown_called = false
76
77
  @deferred_threads = []
@@ -160,12 +161,13 @@ module Fluent
160
161
  end
161
162
 
162
163
  def process(tag, es)
164
+ resolved_table = resolve_table_name(tag)
163
165
  es.each do |time, record|
164
166
  formatted = format(tag, time, record).encode('UTF-8', invalid: :replace, undef: :replace, replace: '_')
165
167
  safe_tag = tag.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '_').gsub(/[^0-9A-Za-z.-]/,
166
168
  '_')
167
169
  blob_name = "fluentd_event_#{safe_tag}.json"
168
- @ingester.upload_data_to_blob_and_queue(formatted, blob_name, @database_name, @table_name,
170
+ @ingester.upload_data_to_blob_and_queue(formatted, blob_name, @database_name, resolved_table,
169
171
  compression_enabled, @ingestion_mapping_reference)
170
172
  rescue StandardError => e
171
173
  @logger&.error("Failed to ingest event to Kusto: #{e}\nEvent skipped: #{record.inspect}\n#{e.backtrace.join("\n")}")
@@ -178,6 +180,7 @@ module Fluent
178
180
  worker_id = Fluent::Engine.worker_id
179
181
  raw_data = chunk.read
180
182
  tag = extract_tag_from_metadata(chunk.metadata)
183
+ resolved_table = resolve_table_name(tag)
181
184
  safe_tag = tag.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '_').gsub(/[^0-9A-Za-z.-]/,
182
185
  '_')
183
186
  unique_id = chunk.unique_id
@@ -185,7 +188,7 @@ module Fluent
185
188
  blob_name = "fluentd_event_worker#{worker_id}_#{safe_tag}_#{dump_unique_id_hex(unique_id)}#{ext}"
186
189
  data_to_upload = compression_enabled ? compress_data(raw_data) : raw_data
187
190
  begin
188
- @ingester.upload_data_to_blob_and_queue(data_to_upload, blob_name, @database_name, @table_name,
191
+ @ingester.upload_data_to_blob_and_queue(data_to_upload, blob_name, @database_name, resolved_table,
189
192
  compression_enabled, @ingestion_mapping_reference)
190
193
  rescue StandardError => e
191
194
  handle_kusto_error(e, unique_id)
@@ -208,6 +211,7 @@ module Fluent
208
211
  def try_write(chunk)
209
212
  @deferred_threads ||= []
210
213
  tag = extract_tag_from_metadata(chunk.metadata)
214
+ resolved_table = resolve_table_name(tag)
211
215
  safe_tag = tag.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '_').gsub(/[^0-9A-Za-z.-]/,
212
216
  '_')
213
217
  chunk_id = dump_unique_id_hex(chunk.unique_id)
@@ -225,7 +229,7 @@ module Fluent
225
229
  row_count = records.size
226
230
  data_to_upload = compression_enabled ? compress_data(updated_raw_data) : updated_raw_data
227
231
  begin
228
- @ingester.upload_data_to_blob_and_queue(data_to_upload, blob_name, @database_name, @table_name,
232
+ @ingester.upload_data_to_blob_and_queue(data_to_upload, blob_name, @database_name, resolved_table,
229
233
  compression_enabled, @ingestion_mapping_reference)
230
234
  if @shutdown_called || !@delayed
231
235
  commit_write(chunk.unique_id)
@@ -287,9 +291,10 @@ module Fluent
287
291
 
288
292
  def check_data_on_server(chunk_id, row_count)
289
293
  # Query Kusto to verify chunk ingestion
294
+ # Note: For dynamic table names, this uses the template name which may not work with placeholders
290
295
  begin
291
296
  # Sanitize inputs to prevent injection attacks
292
- safe_table_name = @table_name.to_s.gsub(/[^a-zA-Z0-9_]/, '')
297
+ safe_table_name = @table_name_template.to_s.gsub(/[^a-zA-Z0-9_${}]/, '')
293
298
  safe_chunk_id = chunk_id.to_s.gsub(/[^a-zA-Z0-9_-]/, '')
294
299
  query = "#{safe_table_name} | extend record_dynamic = parse_json(record) | where record_dynamic.chunk_id == '#{safe_chunk_id}' | count"
295
300
  result = run_kusto_api_query(query, @outconfiguration.kusto_endpoint, @ingester.token_provider,
@@ -340,6 +345,61 @@ module Fluent
340
345
  super
341
346
  end
342
347
 
348
+ def resolve_table_name(tag)
349
+ # Resolve table name from template with placeholders
350
+ if @table_name_template.nil? || @table_name_template.empty?
351
+ @logger&.error('Table name template is nil or empty')
352
+ raise Fluent::ConfigError, 'table_name must be set and non-empty'
353
+ end
354
+
355
+ return @table_name_template unless @table_name_template.include?('${')
356
+
357
+ tag_str = tag.to_s
358
+
359
+ # Validate tag is not empty when using placeholders
360
+ if tag_str.empty?
361
+ @logger&.warn("Tag is empty when resolving dynamic table name, using template as fallback: #{@table_name_template}")
362
+ return @table_name_template.gsub(/\$\{[^}]+\}/, 'unknown').gsub(/[^0-9A-Za-z_]/, '_')
363
+ end
364
+
365
+ tag_parts = tag_str.split('.')
366
+ result = @table_name_template.dup
367
+
368
+ # Replace ${tag} with full tag (dots converted to underscores)
369
+ result = result.gsub('${tag}', tag_str.gsub('.', '_'))
370
+
371
+ # Replace ${tag_parts[N]} with Nth part
372
+ result = result.gsub(/\$\{tag_parts\[(\d+)\]\}/) do
373
+ index = ::Regexp.last_match(1).to_i
374
+ tag_parts[index] || 'unknown'
375
+ end
376
+
377
+ # Replace ${tag_prefix[N]} with first N parts
378
+ result = result.gsub(/\$\{tag_prefix\[(\d+)\]\}/) do
379
+ count = ::Regexp.last_match(1).to_i
380
+ parts = tag_parts.take(count)
381
+ parts.empty? ? 'unknown' : parts.join('_')
382
+ end
383
+
384
+ # Replace ${tag_suffix[N]} with last N parts
385
+ result = result.gsub(/\$\{tag_suffix\[(\d+)\]\}/) do
386
+ count = ::Regexp.last_match(1).to_i
387
+ parts = tag_parts.last(count)
388
+ parts.empty? ? 'unknown' : parts.join('_')
389
+ end
390
+
391
+ # Sanitize: replace special characters with underscores and collapse consecutive underscores
392
+ sanitized = result.gsub(/[^0-9A-Za-z_]/, '_').gsub(/_+/, '_')
393
+
394
+ # Final validation: ensure we don't have an empty table name
395
+ if sanitized.empty? || sanitized == '_'
396
+ @logger&.error("Resolved table name is empty or invalid for tag '#{tag}' with template '#{@table_name_template}'")
397
+ raise Fluent::ConfigError, "table_name resolved to empty or invalid value for tag '#{tag}'"
398
+ end
399
+
400
+ sanitized
401
+ end
402
+
343
403
  private
344
404
 
345
405
  def validate_buffer_config(conf)
@@ -0,0 +1,320 @@
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 KustoOutputResolveTableNameTest < Test::Unit::TestCase
11
+ setup do
12
+ Fluent::Test.setup
13
+ end
14
+
15
+ def create_driver(table_name = 'testtable')
16
+ Fluent::Test::Driver::Output.new(Fluent::Plugin::KustoOutput).configure(<<-CONF)
17
+ @type kusto
18
+ endpoint https://example.kusto.windows.net
19
+ database_name testdb
20
+ table_name #{table_name}
21
+ client_id dummy-client-id
22
+ client_secret dummy-secret
23
+ tenant_id dummy-tenant
24
+ buffered true
25
+ auth_type aad
26
+ CONF
27
+ end
28
+
29
+ def logger_stub
30
+ m = mock
31
+ m.stubs(:debug)
32
+ m.stubs(:error)
33
+ m.stubs(:info)
34
+ m.stubs(:warn)
35
+ m
36
+ end
37
+
38
+ def ingester_stub
39
+ m = mock
40
+ m.stubs(:upload_data_to_blob_and_queue)
41
+ m
42
+ end
43
+
44
+ def set_mocks(driver, ingester: nil, logger: nil)
45
+ driver.instance.instance_variable_set(:@ingester, ingester) if ingester
46
+ driver.instance.instance_variable_set(:@logger, logger) if logger
47
+ end
48
+
49
+ def chunk_stub(data: 'testdata', tag: 'test.tag', unique_id: 'uniqueid'.b, metadata: nil)
50
+ c = mock
51
+ c.stubs(:read).returns(data)
52
+ c.stubs(:metadata).returns(metadata || OpenStruct.new(tag: tag))
53
+ c.stubs(:unique_id).returns(unique_id)
54
+ c
55
+ end
56
+
57
+ # ==========================================
58
+ # resolve_table_name method tests
59
+ # ==========================================
60
+
61
+ test 'resolve_table_name returns static table name when no placeholders' do
62
+ driver = create_driver('MyStaticTable')
63
+ result = driver.instance.send(:resolve_table_name, 'app.orders.created')
64
+ assert_equal 'MyStaticTable', result
65
+ end
66
+
67
+ test 'resolve_table_name replaces ${tag} with full tag' do
68
+ driver = create_driver('${tag}')
69
+ result = driver.instance.send(:resolve_table_name, 'app.orders.created')
70
+ assert_equal 'app_orders_created', result # dots replaced with underscores
71
+ end
72
+
73
+ test 'resolve_table_name replaces ${tag_parts[0]} with first part' do
74
+ driver = create_driver('${tag_parts[0]}')
75
+ result = driver.instance.send(:resolve_table_name, 'app.orders.created')
76
+ assert_equal 'app', result
77
+ end
78
+
79
+ test 'resolve_table_name replaces ${tag_parts[1]} with second part' do
80
+ driver = create_driver('${tag_parts[1]}')
81
+ result = driver.instance.send(:resolve_table_name, 'custom.orders.created')
82
+ assert_equal 'orders', result
83
+ end
84
+
85
+ test 'resolve_table_name replaces ${tag_parts[2]} with third part' do
86
+ driver = create_driver('${tag_parts[2]}')
87
+ result = driver.instance.send(:resolve_table_name, 'custom.orders.created')
88
+ assert_equal 'created', result
89
+ end
90
+
91
+ test 'resolve_table_name returns empty string for out-of-bounds tag_parts index' do
92
+ driver = create_driver('${tag_parts[5]}')
93
+ result = driver.instance.send(:resolve_table_name, 'app.orders')
94
+ assert_equal '', result
95
+ end
96
+
97
+ test 'resolve_table_name replaces ${tag_prefix[1]} with first part' do
98
+ driver = create_driver('${tag_prefix[1]}')
99
+ result = driver.instance.send(:resolve_table_name, 'app.orders.created')
100
+ assert_equal 'app', result
101
+ end
102
+
103
+ test 'resolve_table_name replaces ${tag_prefix[2]} with first two parts' do
104
+ driver = create_driver('${tag_prefix[2]}')
105
+ result = driver.instance.send(:resolve_table_name, 'app.orders.created')
106
+ assert_equal 'app_orders', result # dot replaced with underscore
107
+ end
108
+
109
+ test 'resolve_table_name replaces ${tag_suffix[1]} with last part' do
110
+ driver = create_driver('${tag_suffix[1]}')
111
+ result = driver.instance.send(:resolve_table_name, 'app.orders.created')
112
+ assert_equal 'created', result
113
+ end
114
+
115
+ test 'resolve_table_name replaces ${tag_suffix[2]} with last two parts' do
116
+ driver = create_driver('${tag_suffix[2]}')
117
+ result = driver.instance.send(:resolve_table_name, 'app.orders.created')
118
+ assert_equal 'orders_created', result # dot replaced with underscore
119
+ end
120
+
121
+ test 'resolve_table_name handles mixed static and placeholder' do
122
+ driver = create_driver('prefix_${tag_parts[1]}_suffix')
123
+ result = driver.instance.send(:resolve_table_name, 'custom.orders.events')
124
+ assert_equal 'prefix_orders_suffix', result
125
+ end
126
+
127
+ test 'resolve_table_name handles multiple placeholders' do
128
+ driver = create_driver('${tag_parts[0]}_${tag_parts[1]}')
129
+ result = driver.instance.send(:resolve_table_name, 'env.production.logs')
130
+ assert_equal 'env_production', result
131
+ end
132
+
133
+ test 'resolve_table_name sanitizes special characters' do
134
+ driver = create_driver('${tag}')
135
+ result = driver.instance.send(:resolve_table_name, 'app-name.service:type.logs')
136
+ assert_equal 'app_name_service_type_logs', result # special chars replaced with underscores
137
+ end
138
+
139
+ test 'resolve_table_name handles single-part tag' do
140
+ driver = create_driver('${tag_parts[0]}')
141
+ result = driver.instance.send(:resolve_table_name, 'singletag')
142
+ assert_equal 'singletag', result
143
+ end
144
+
145
+ test 'resolve_table_name returns empty for tag_parts on single-part tag with index > 0' do
146
+ driver = create_driver('${tag_parts[1]}')
147
+ result = driver.instance.send(:resolve_table_name, 'singletag')
148
+ assert_equal '', result
149
+ end
150
+
151
+ test 'resolve_table_name handles empty tag' do
152
+ driver = create_driver('${tag}')
153
+ result = driver.instance.send(:resolve_table_name, '')
154
+ assert_equal '', result
155
+ end
156
+
157
+ test 'resolve_table_name handles nil tag' do
158
+ driver = create_driver('${tag}')
159
+ result = driver.instance.send(:resolve_table_name, nil)
160
+ assert_equal '', result
161
+ end
162
+
163
+ # ==========================================
164
+ # Integration tests with write method
165
+ # ==========================================
166
+
167
+ test 'write uses resolved table name with tag_parts placeholder' do
168
+ driver = create_driver('${tag_parts[1]}')
169
+ ingester_mock = mock
170
+ # Verify the table name passed to ingester is 'orders' (from tag custom.orders.events)
171
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once.with do |_data, _blob_name, db, table, _compression, _mapping|
172
+ assert_equal 'testdb', db
173
+ assert_equal 'orders', table
174
+ true
175
+ end
176
+ set_mocks(driver, ingester: ingester_mock, logger: logger_stub)
177
+ chunk = chunk_stub(tag: 'custom.orders.events')
178
+ assert_nothing_raised { driver.instance.write(chunk) }
179
+ end
180
+
181
+ test 'write uses resolved table name with tag placeholder' do
182
+ driver = create_driver('${tag}')
183
+ ingester_mock = mock
184
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once.with do |_data, _blob_name, db, table, _compression, _mapping|
185
+ assert_equal 'testdb', db
186
+ assert_equal 'app_service_logs', table # dots converted to underscores
187
+ true
188
+ end
189
+ set_mocks(driver, ingester: ingester_mock, logger: logger_stub)
190
+ chunk = chunk_stub(tag: 'app.service.logs')
191
+ assert_nothing_raised { driver.instance.write(chunk) }
192
+ end
193
+
194
+ test 'write uses static table name when no placeholders' do
195
+ driver = create_driver('MyStaticTable')
196
+ ingester_mock = mock
197
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once.with do |_data, _blob_name, db, table, _compression, _mapping|
198
+ assert_equal 'testdb', db
199
+ assert_equal 'MyStaticTable', table
200
+ true
201
+ end
202
+ set_mocks(driver, ingester: ingester_mock, logger: logger_stub)
203
+ chunk = chunk_stub(tag: 'any.tag.here')
204
+ assert_nothing_raised { driver.instance.write(chunk) }
205
+ end
206
+
207
+ test 'write routes different tags to different tables' do
208
+ driver = create_driver('${tag_parts[1]}')
209
+ ingester_mock = mock
210
+
211
+ # First call should use 'orders' table
212
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once.with do |_data, _blob_name, db, table, _compression, _mapping|
213
+ assert_equal 'orders', table
214
+ true
215
+ end
216
+
217
+ set_mocks(driver, ingester: ingester_mock, logger: logger_stub)
218
+ chunk1 = chunk_stub(tag: 'custom.orders.created')
219
+ assert_nothing_raised { driver.instance.write(chunk1) }
220
+
221
+ # Second call should use 'users' table
222
+ ingester_mock2 = mock
223
+ ingester_mock2.expects(:upload_data_to_blob_and_queue).once.with do |_data, _blob_name, db, table, _compression, _mapping|
224
+ assert_equal 'users', table
225
+ true
226
+ end
227
+ set_mocks(driver, ingester: ingester_mock2, logger: logger_stub)
228
+ chunk2 = chunk_stub(tag: 'custom.users.signup')
229
+ assert_nothing_raised { driver.instance.write(chunk2) }
230
+ end
231
+
232
+ # ==========================================
233
+ # Edge cases and special characters
234
+ # ==========================================
235
+
236
+ test 'resolve_table_name handles tag with numbers' do
237
+ driver = create_driver('${tag_parts[1]}')
238
+ result = driver.instance.send(:resolve_table_name, 'app.table123.logs')
239
+ assert_equal 'table123', result
240
+ end
241
+
242
+ test 'resolve_table_name handles tag with underscores' do
243
+ driver = create_driver('${tag_parts[1]}')
244
+ result = driver.instance.send(:resolve_table_name, 'app.my_table.logs')
245
+ assert_equal 'my_table', result
246
+ end
247
+
248
+ test 'resolve_table_name preserves underscores' do
249
+ driver = create_driver('my_${tag_parts[1]}_table')
250
+ result = driver.instance.send(:resolve_table_name, 'app.orders.logs')
251
+ assert_equal 'my_orders_table', result
252
+ end
253
+
254
+ test 'resolve_table_name handles deeply nested tags' do
255
+ driver = create_driver('${tag_parts[3]}')
256
+ result = driver.instance.send(:resolve_table_name, 'level1.level2.level3.level4.level5')
257
+ assert_equal 'level4', result
258
+ end
259
+
260
+ test 'resolve_table_name with tag_prefix handles more parts than exist' do
261
+ driver = create_driver('${tag_prefix[10]}')
262
+ result = driver.instance.send(:resolve_table_name, 'app.orders')
263
+ assert_equal 'app_orders', result # Returns all parts when count exceeds available
264
+ end
265
+
266
+ test 'resolve_table_name with tag_suffix handles more parts than exist' do
267
+ driver = create_driver('${tag_suffix[10]}')
268
+ result = driver.instance.send(:resolve_table_name, 'app.orders')
269
+ assert_equal 'app_orders', result # Returns all parts when count exceeds available
270
+ end
271
+
272
+ # ==========================================
273
+ # Tests for try_write with dynamic table names
274
+ # ==========================================
275
+
276
+ test 'try_write uses resolved table name' do
277
+ driver = create_driver('${tag_parts[1]}')
278
+ driver.instance.instance_variable_set(:@delayed, false)
279
+ driver.instance.instance_variable_set(:@shutdown_called, false)
280
+
281
+ ingester_mock = mock
282
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once.with do |_data, _blob_name, db, table, _compression, _mapping|
283
+ assert_equal 'orders', table
284
+ true
285
+ end
286
+
287
+ set_mocks(driver, ingester: ingester_mock, logger: logger_stub)
288
+
289
+ # Need to stub commit_write since we're in non-delayed mode
290
+ driver.instance.stubs(:commit_write)
291
+
292
+ chunk = chunk_stub(
293
+ data: '{"tag":"custom.orders","timestamp":"2024-01-01","record":{"key":"value"}}',
294
+ tag: 'custom.orders.events'
295
+ )
296
+ assert_nothing_raised { driver.instance.try_write(chunk) }
297
+ end
298
+
299
+ # ==========================================
300
+ # Tests for process with dynamic table names
301
+ # ==========================================
302
+
303
+ test 'process uses resolved table name' do
304
+ driver = create_driver('${tag_parts[1]}')
305
+
306
+ ingester_mock = mock
307
+ ingester_mock.expects(:upload_data_to_blob_and_queue).once.with do |_data, _blob_name, db, table, _compression, _mapping|
308
+ assert_equal 'orders', table
309
+ true
310
+ end
311
+
312
+ set_mocks(driver, ingester: ingester_mock, logger: logger_stub)
313
+
314
+ # Create a simple event stream
315
+ es = mock
316
+ es.stubs(:each).yields(Time.now.to_i, {'message' => 'test'})
317
+
318
+ assert_nothing_raised { driver.instance.process('custom.orders.events', es) }
319
+ end
320
+ end
metadata CHANGED
@@ -1,15 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fluent-plugin-kusto
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.1.beta
5
5
  platform: ruby
6
6
  authors:
7
7
  - Komal Rani
8
8
  - Kusto OSS IDC Team
9
- autorequire:
10
9
  bindir: bin
11
10
  cert_chain: []
12
- date: 2025-12-09 00:00:00.000000000 Z
11
+ date: 1980-01-02 00:00:00.000000000 Z
13
12
  dependencies:
14
13
  - !ruby/object:Gem::Dependency
15
14
  name: bundler
@@ -150,21 +149,19 @@ files:
150
149
  - test/helper.rb
151
150
  - test/plugin/test_azcli_tokenprovider.rb
152
151
  - test/plugin/test_e2e_kusto.rb
153
- - test/plugin/test_mi_tokenprovider.rb
154
152
  - test/plugin/test_out_kusto_config.rb
155
153
  - test/plugin/test_out_kusto_format.rb
156
154
  - test/plugin/test_out_kusto_process.rb
155
+ - test/plugin/test_out_kusto_resolve_table_name.rb
157
156
  - test/plugin/test_out_kusto_start.rb
158
157
  - test/plugin/test_out_kusto_try_write.rb
159
158
  - test/plugin/test_out_kusto_write.rb
160
- - test/plugin/test_wif_tokenprovider.rb
161
159
  homepage: https://github.com/Azure/azure-kusto-fluentd
162
160
  licenses:
163
161
  - Apache-2.0
164
162
  metadata:
165
163
  fluentd_plugin: 'true'
166
164
  fluentd_group: output
167
- post_install_message:
168
165
  rdoc_options: []
169
166
  require_paths:
170
167
  - lib
@@ -179,19 +176,17 @@ required_rubygems_version: !ruby/object:Gem::Requirement
179
176
  - !ruby/object:Gem::Version
180
177
  version: '0'
181
178
  requirements: []
182
- rubygems_version: 3.0.3.1
183
- signing_key:
179
+ rubygems_version: 3.7.1
184
180
  specification_version: 4
185
181
  summary: A custom Fluentd output plugin for Azure Kusto ingestion.
186
182
  test_files:
187
183
  - test/helper.rb
188
184
  - test/plugin/test_azcli_tokenprovider.rb
189
185
  - test/plugin/test_e2e_kusto.rb
190
- - test/plugin/test_mi_tokenprovider.rb
191
186
  - test/plugin/test_out_kusto_config.rb
192
187
  - test/plugin/test_out_kusto_format.rb
193
188
  - test/plugin/test_out_kusto_process.rb
189
+ - test/plugin/test_out_kusto_resolve_table_name.rb
194
190
  - test/plugin/test_out_kusto_start.rb
195
191
  - test/plugin/test_out_kusto_try_write.rb
196
192
  - test/plugin/test_out_kusto_write.rb
197
- - test/plugin/test_wif_tokenprovider.rb
@@ -1,165 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'test/unit'
4
- require 'mocha/test_unit'
5
- require_relative '../../lib/fluent/plugin/auth/mi_tokenprovider'
6
-
7
- class DummyConfigForMI
8
- attr_reader :kusto_endpoint, :managed_identity_client_id
9
-
10
- def initialize(kusto_endpoint, managed_identity_client_id = nil)
11
- @kusto_endpoint = kusto_endpoint
12
- @managed_identity_client_id = managed_identity_client_id
13
- end
14
-
15
- def logger
16
- require 'logger'
17
- Logger.new($stdout)
18
- end
19
- end
20
-
21
- class ManagedIdentityTokenProviderTest < Test::Unit::TestCase
22
- def setup
23
- @resource = 'https://kusto.kusto.windows.net'
24
- @client_id = '074c3c54-29e2-4230-a81f-333868b8d6ca'
25
- end
26
-
27
- def test_initialize_with_user_managed_identity
28
- config = DummyConfigForMI.new(@resource, @client_id)
29
- provider = ManagedIdentityTokenProvider.new(config)
30
-
31
- # Verify instance variables are set correctly
32
- assert_equal @resource, provider.instance_variable_get(:@resource)
33
- assert_equal @client_id, provider.instance_variable_get(:@managed_identity_client_id)
34
- assert_equal false, provider.instance_variable_get(:@use_system_assigned)
35
- assert_equal true, provider.instance_variable_get(:@use_user_assigned)
36
- assert_not_nil provider.instance_variable_get(:@token_acquire_url)
37
- end
38
-
39
- def test_initialize_with_system_managed_identity
40
- config = DummyConfigForMI.new(@resource, 'SYSTEM')
41
- provider = ManagedIdentityTokenProvider.new(config)
42
-
43
- # Verify instance variables are set correctly for system managed identity
44
- assert_equal @resource, provider.instance_variable_get(:@resource)
45
- assert_equal 'SYSTEM', provider.instance_variable_get(:@managed_identity_client_id)
46
- assert_equal true, provider.instance_variable_get(:@use_system_assigned)
47
- assert_equal false, provider.instance_variable_get(:@use_user_assigned)
48
- assert_not_nil provider.instance_variable_get(:@token_acquire_url)
49
- end
50
-
51
- def test_initialize_with_empty_client_id
52
- config = DummyConfigForMI.new(@resource, '')
53
- provider = ManagedIdentityTokenProvider.new(config)
54
-
55
- # Verify instance variables are set correctly for empty client_id
56
- assert_equal @resource, provider.instance_variable_get(:@resource)
57
- assert_equal '', provider.instance_variable_get(:@managed_identity_client_id)
58
- assert_equal false, provider.instance_variable_get(:@use_system_assigned)
59
- assert_equal false, provider.instance_variable_get(:@use_user_assigned)
60
- end
61
-
62
- def test_token_acquire_url_formation_user_managed_identity
63
- config = DummyConfigForMI.new(@resource, @client_id)
64
- provider = ManagedIdentityTokenProvider.new(config)
65
-
66
- token_url = provider.instance_variable_get(:@token_acquire_url)
67
- expected_base = 'http://169.254.169.254/metadata/identity/oauth2/token'
68
-
69
- assert token_url.include?(expected_base), "Token URL should contain base IMDS endpoint"
70
- assert token_url.include?('resource=https%3A%2F%2Fkusto.kusto.windows.net'), "Token URL should contain encoded resource"
71
- assert token_url.include?('api-version=2018-02-01'), "Token URL should contain API version"
72
- assert token_url.include?("client_id=#{@client_id}"), "Token URL should contain client_id for user managed identity"
73
- end
74
-
75
- def test_token_acquire_url_formation_system_managed_identity
76
- config = DummyConfigForMI.new(@resource, 'SYSTEM')
77
- provider = ManagedIdentityTokenProvider.new(config)
78
-
79
- token_url = provider.instance_variable_get(:@token_acquire_url)
80
- expected_base = 'http://169.254.169.254/metadata/identity/oauth2/token'
81
-
82
- assert token_url.include?(expected_base), "Token URL should contain base IMDS endpoint"
83
- assert token_url.include?('resource=https%3A%2F%2Fkusto.kusto.windows.net'), "Token URL should contain encoded resource"
84
- assert token_url.include?('api-version=2018-02-01'), "Token URL should contain API version"
85
- assert !token_url.include?('client_id='), "Token URL should NOT contain client_id for system managed identity"
86
- end
87
-
88
- def test_fetch_token_success
89
- config = DummyConfigForMI.new(@resource, @client_id)
90
- provider = ManagedIdentityTokenProvider.new(config)
91
-
92
- # Mock successful HTTP response
93
- mock_response = {
94
- 'access_token' => 'fake-access-token',
95
- 'expires_in' => 3600
96
- }
97
-
98
- provider.stubs(:post_token_request).returns(mock_response)
99
-
100
- result = provider.send(:fetch_token)
101
- assert_equal 'fake-access-token', result[:access_token]
102
- assert_equal 3600, result[:expires_in]
103
- end
104
-
105
- def test_post_token_request_retries_on_failure
106
- config = DummyConfigForMI.new(@resource, @client_id)
107
- provider = ManagedIdentityTokenProvider.new(config)
108
-
109
- # Mock HTTP failure followed by success
110
- mock_http = mock
111
- mock_response_fail = mock
112
- mock_response_fail.stubs(:code).returns(500)
113
- mock_response_fail.stubs(:body).returns('Internal Server Error')
114
-
115
- mock_response_success = mock
116
- mock_response_success.stubs(:code).returns(200)
117
- mock_response_success.stubs(:body).returns('{"access_token":"fake-token","expires_in":3600}')
118
-
119
- mock_request = mock
120
- Net::HTTP::Get.stubs(:new).returns(mock_request)
121
-
122
- # Mock the HTTP client setup from create_http_client method
123
- mock_http.stubs(:use_ssl=)
124
- mock_http.stubs(:open_timeout=)
125
- mock_http.stubs(:read_timeout=)
126
- mock_http.stubs(:write_timeout=)
127
- mock_http.expects(:request).twice.returns(mock_response_fail, mock_response_success)
128
- Net::HTTP.stubs(:new).returns(mock_http)
129
-
130
- # Stub sleep to speed up test
131
- provider.stubs(:sleep)
132
-
133
- result = provider.send(:post_token_request)
134
- assert_equal 'fake-token', result['access_token']
135
- end
136
-
137
- def test_post_token_request_raises_after_max_retries
138
- config = DummyConfigForMI.new(@resource, @client_id)
139
- provider = ManagedIdentityTokenProvider.new(config)
140
-
141
- # Mock HTTP failure for all attempts
142
- mock_http = mock
143
- mock_response_fail = mock
144
- mock_response_fail.stubs(:code).returns(500)
145
- mock_response_fail.stubs(:body).returns('Internal Server Error')
146
-
147
- mock_request = mock
148
- Net::HTTP::Get.stubs(:new).returns(mock_request)
149
-
150
- # Mock the HTTP client setup from create_http_client method
151
- mock_http.stubs(:use_ssl=)
152
- mock_http.stubs(:open_timeout=)
153
- mock_http.stubs(:read_timeout=)
154
- mock_http.stubs(:write_timeout=)
155
- mock_http.stubs(:request).returns(mock_response_fail)
156
- Net::HTTP.stubs(:new).returns(mock_http)
157
-
158
- # Stub sleep to speed up test
159
- provider.stubs(:sleep)
160
-
161
- assert_raise(RuntimeError, 'Failed to get managed identity token after 2 attempts.') do
162
- provider.send(:post_token_request)
163
- end
164
- end
165
- end
@@ -1,145 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'test/unit'
4
- require 'mocha/test_unit'
5
- require_relative '../../lib/fluent/plugin/auth/wif_tokenprovider'
6
-
7
- class DummyConfigForWIF
8
- attr_reader :kusto_endpoint, :workload_identity_client_id, :workload_identity_tenant_id, :workload_identity_token_file_path
9
-
10
- def initialize(kusto_endpoint, client_id, tenant_id, token_file_path = nil)
11
- @kusto_endpoint = kusto_endpoint
12
- @workload_identity_client_id = client_id
13
- @workload_identity_tenant_id = tenant_id
14
- @workload_identity_token_file_path = token_file_path
15
- end
16
-
17
- def logger
18
- require 'logger'
19
- Logger.new($stdout)
20
- end
21
- end
22
-
23
- class WorkloadIdentityTest < Test::Unit::TestCase
24
- def setup
25
- @resource = 'https://kusto.kusto.windows.net'
26
- @client_id = '074c3c54-29e2-4230-a81f-333868b8d6ca'
27
- @tenant_id = '12345678-1234-1234-1234-123456789abc'
28
- @token_file = '/tmp/test-token'
29
- end
30
-
31
- def test_initialize_with_custom_token_file
32
- config = DummyConfigForWIF.new(@resource, @client_id, @tenant_id, @token_file)
33
- provider = WorkloadIdentity.new(config)
34
-
35
- # Verify instance variables are set correctly
36
- assert_equal @resource, provider.instance_variable_get(:@kusto_endpoint)
37
- assert_equal @client_id, provider.instance_variable_get(:@client_id)
38
- assert_equal @tenant_id, provider.instance_variable_get(:@tenant_id)
39
- assert_equal @token_file, provider.instance_variable_get(:@token_file)
40
- assert_equal "#{@resource}/.default", provider.instance_variable_get(:@scope)
41
- end
42
-
43
- def test_initialize_with_default_token_file
44
- config = DummyConfigForWIF.new(@resource, @client_id, @tenant_id, nil)
45
- provider = WorkloadIdentity.new(config)
46
-
47
- # Verify default token file is used
48
- expected_default = '/var/run/secrets/azure/tokens/azure-identity-token'
49
- assert_equal expected_default, provider.instance_variable_get(:@token_file)
50
- end
51
-
52
- def test_fetch_token_success
53
- config = DummyConfigForWIF.new(@resource, @client_id, @tenant_id, @token_file)
54
- provider = WorkloadIdentity.new(config)
55
-
56
- # Mock successful token acquisition
57
- mock_response = {
58
- 'access_token' => 'wif-access-token',
59
- 'expires_in' => 7200
60
- }
61
-
62
- provider.stubs(:acquire_workload_identity_token).returns(mock_response)
63
-
64
- result = provider.send(:fetch_token)
65
- assert_equal 'wif-access-token', result[:access_token]
66
- assert_equal 7200, result[:expires_in]
67
- end
68
-
69
- def test_acquire_workload_identity_token_success
70
- config = DummyConfigForWIF.new(@resource, @client_id, @tenant_id, @token_file)
71
- provider = WorkloadIdentity.new(config)
72
-
73
- # Mock file read and HTTP request
74
- File.stubs(:read).with(@token_file).returns('fake-oidc-token')
75
-
76
- mock_response = mock
77
- mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
78
- mock_response.stubs(:body).returns('{"access_token":"wif-token","expires_in":7200}')
79
-
80
- mock_http = mock
81
- mock_http.expects(:use_ssl=).with(true)
82
- mock_http.expects(:open_timeout=).with(10)
83
- mock_http.expects(:read_timeout=).with(30)
84
- mock_http.expects(:write_timeout=).with(10)
85
- mock_http.expects(:request).returns(mock_response)
86
-
87
- Net::HTTP.stubs(:new).returns(mock_http)
88
-
89
- result = provider.send(:acquire_workload_identity_token)
90
- assert_equal 'wif-token', result['access_token']
91
- assert_equal 7200, result['expires_in']
92
- end
93
-
94
- def test_acquire_workload_identity_token_failure
95
- config = DummyConfigForWIF.new(@resource, @client_id, @tenant_id, @token_file)
96
- provider = WorkloadIdentity.new(config)
97
-
98
- # Mock file read and HTTP request failure
99
- File.stubs(:read).with(@token_file).returns('fake-oidc-token')
100
-
101
- mock_response = mock
102
- mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(false)
103
- mock_response.stubs(:code).returns(400)
104
- mock_response.stubs(:body).returns('Bad Request')
105
-
106
- mock_http = mock
107
- mock_http.expects(:use_ssl=).with(true)
108
- mock_http.expects(:open_timeout=).with(10)
109
- mock_http.expects(:read_timeout=).with(30)
110
- mock_http.expects(:write_timeout=).with(10)
111
- mock_http.expects(:request).returns(mock_response)
112
-
113
- Net::HTTP.stubs(:new).returns(mock_http)
114
-
115
- assert_raise(RuntimeError, 'Failed to get access token: 400 Bad Request') do
116
- provider.send(:acquire_workload_identity_token)
117
- end
118
- end
119
-
120
- def test_oauth2_endpoint_formation
121
- config = DummyConfigForWIF.new(@resource, @client_id, @tenant_id, @token_file)
122
- provider = WorkloadIdentity.new(config)
123
-
124
- # Test that the endpoint is formatted correctly with tenant_id
125
- File.stubs(:read).with(@token_file).returns('fake-oidc-token')
126
-
127
- # Mock the rest of the HTTP request to avoid actual network call
128
- mock_response = mock
129
- mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
130
- mock_response.stubs(:body).returns('{"access_token":"test","expires_in":3600}')
131
-
132
- mock_http = mock
133
- mock_http.expects(:use_ssl=).with(true)
134
- mock_http.expects(:open_timeout=).with(10)
135
- mock_http.expects(:read_timeout=).with(30)
136
- mock_http.expects(:write_timeout=).with(10)
137
- mock_http.expects(:request).returns(mock_response)
138
-
139
- Net::HTTP.stubs(:new).returns(mock_http)
140
-
141
- # Just verify it doesn't crash - the important thing is that setup_config was called
142
- result = provider.send(:acquire_workload_identity_token)
143
- assert_equal 'test', result['access_token']
144
- end
145
- end