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,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Client handles authentication and resource fetching for Azure Data Explorer (Kusto) ingestion.
4
+ # It supports both Managed Identity and AAD Client Credentials authentication methods.
5
+ #
6
+ # The Client class is responsible for:
7
+ # - Authenticating using Managed Identity or AAD Client Credentials
8
+ # - Fetching and caching Kusto ingestion resources
9
+ # - Providing access to blob SAS URI, queue SAS URI, and identity token
10
+ require_relative 'auth/aad_tokenprovider'
11
+ require_relative 'auth/mi_tokenprovider'
12
+ require_relative 'auth/azcli_tokenprovider'
13
+ require_relative 'auth/wif_tokenprovider'
14
+ require_relative 'kusto_query'
15
+ require 'logger'
16
+
17
+ class Client
18
+ def initialize(outconfiguration)
19
+ # Set up queries for resource fetching
20
+ @user_query_blob_container = '.get ingestion resources'
21
+ @user_query_aad_token = '.get kusto identity token'
22
+ # Use provided logger or default to stdout
23
+ @logger = initialize_logger(outconfiguration)
24
+ @data_endpoint = outconfiguration.kusto_endpoint
25
+ @cached_resources = nil
26
+ @resources_expiry_time = nil
27
+ @outconfiguration = outconfiguration
28
+ @token_provider = create_token_provider(outconfiguration)
29
+ end
30
+
31
+ def resources
32
+ # Return cached resources if valid, otherwise fetch and cache
33
+ return @cached_resources if resources_cached?
34
+
35
+ fetch_and_cache_resources
36
+ @cached_resources
37
+ end
38
+
39
+ attr_reader :blob_sas_uri, :queue_sas_uri, :identity_token, :logger, :blob_rows, :data_endpoint, :token_provider
40
+
41
+ private
42
+
43
+ def initialize_logger(outconfiguration)
44
+ # Prefer logger from configuration, fallback to stdout
45
+ outconfiguration.logger
46
+ rescue StandardError
47
+ Logger.new($stdout)
48
+ end
49
+
50
+ def resources_cached?
51
+ # Check if resources are cached and not expired
52
+ @cached_resources && @resources_expiry_time && @resources_expiry_time > Time.now
53
+ end
54
+
55
+ def fetch_and_cache_resources
56
+ # Fetch resources from Kusto and cache them
57
+ @logger.info('Fetching resources from Kusto...')
58
+ blob_rows, aad_token_rows = fetch_kusto_resources
59
+ return unless blob_rows && aad_token_rows
60
+
61
+ blob_sas_uri, queue_sas_uri, identity_token = extract_resource_uris(blob_rows, aad_token_rows)
62
+ return unless validate_resource_uris(blob_sas_uri, queue_sas_uri, identity_token)
63
+
64
+ assign_and_cache_resources(blob_sas_uri, queue_sas_uri, identity_token)
65
+ end
66
+
67
+ def fetch_kusto_resources
68
+ # Fetch resource rows and validate them
69
+ blob_rows, aad_token_rows = fetch_kusto_rows_with_error_handling
70
+ validate_kusto_resource_rows(blob_rows, aad_token_rows)
71
+ end
72
+
73
+ def fetch_kusto_rows_with_error_handling
74
+ # Fetch blob and AAD token rows with error handling
75
+ blob_rows = fetch_blob_rows
76
+ aad_token_rows = fetch_aad_token_rows
77
+ [blob_rows, aad_token_rows]
78
+ end
79
+
80
+ def fetch_blob_rows
81
+ # Run Kusto query for blob resources
82
+ run_kusto_api_query(@user_query_blob_container, @data_endpoint, @token_provider,
83
+ use_ingest_endpoint: true)
84
+ rescue StandardError => e
85
+ @logger.error("Failed to fetch blob resources from Kusto: #{e.message}")
86
+ nil
87
+ end
88
+
89
+ def fetch_aad_token_rows
90
+ # Run Kusto query for AAD token resources
91
+ run_kusto_api_query(@user_query_aad_token, @data_endpoint, @token_provider,
92
+ use_ingest_endpoint: true, database_name: nil)
93
+ rescue StandardError => e
94
+ @logger.error("Failed to fetch AAD token resources from Kusto: #{e.message}")
95
+ nil
96
+ end
97
+
98
+ def create_token_provider(outconfiguration)
99
+ case outconfiguration.auth_type&.downcase
100
+ when 'aad'
101
+ AadTokenProvider.new(outconfiguration)
102
+ when 'azcli'
103
+ AzCliTokenProvider.new(outconfiguration)
104
+ when 'workload_identity'
105
+ WorkloadIdentity.new(outconfiguration)
106
+ when 'user_managed_identity', 'system_managed_identity'
107
+ ManagedIdentityTokenProvider.new(outconfiguration)
108
+ else
109
+ raise "Unknown auth_type: #{outconfiguration.auth_type}"
110
+ end
111
+ end
112
+
113
+ def extract_resource_uris(blob_rows, aad_token_rows)
114
+ # Extract URIs from resource rows
115
+ blob_sas_uri = blob_rows.find { |row| row[0] == 'TempStorage' }&.[](1)
116
+ queue_sas_uri = blob_rows.find { |row| row[0] == 'SecuredReadyForAggregationQueue' }&.[](1)
117
+ identity_token = aad_token_rows[0][0] if aad_token_rows.any?
118
+ [blob_sas_uri, queue_sas_uri, identity_token]
119
+ end
120
+
121
+ def validate_resource_uris(blob_sas_uri, queue_sas_uri, identity_token)
122
+ # Ensure all required URIs are present
123
+ if blob_sas_uri.nil? || queue_sas_uri.nil? || identity_token.nil?
124
+ @logger.error('Failed to retrieve all required resources: blob_sas_uri, queue_s_uri, or identity_token is nil.')
125
+ return false
126
+ end
127
+ true
128
+ end
129
+
130
+ def assign_and_cache_resources(blob_sas_uri, queue_sas_uri, identity_token)
131
+ # Assign and cache resource URIs
132
+ @blob_sas_uri = blob_sas_uri
133
+ @queue_sas_uri = queue_sas_uri
134
+ @identity_token = identity_token
135
+ @cached_resources = {
136
+ blob_sas_uri: blob_sas_uri,
137
+ queue_sas_uri: queue_sas_uri,
138
+ identity_token: identity_token
139
+ }
140
+ @resources_expiry_time = Time.now + 21_600 # Cache for 6 hours
141
+ end
142
+
143
+ def validate_kusto_resource_rows(blob_rows, aad_token_rows)
144
+ # Validate resource rows are present
145
+ if blob_rows.nil? || blob_rows.empty?
146
+ @logger.error('No blob rows found in the response.')
147
+ return [nil, nil]
148
+ end
149
+ if aad_token_rows.nil? || aad_token_rows.empty?
150
+ @logger.error('No AAD token rows found in the response.')
151
+ return [nil, nil]
152
+ end
153
+ [blob_rows, aad_token_rows]
154
+ end
155
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ # OutputConfiguration holds and validates configuration for the Kusto output plugin.
4
+ # It supports both Managed Identity and AAD Client Credentials authentication methods.
5
+ #
6
+ # Responsibilities:
7
+ # - Store configuration options
8
+ # - Validate configuration for Managed Identity or AAD
9
+ # - Provide Azure AD endpoint based on cloud
10
+
11
+ require 'logger'
12
+
13
+ AZURE_CLOUDS = {
14
+ 'AzureCloud' => { 'aad' => 'https://login.microsoftonline.com' },
15
+ 'AzureChinaCloud' => { 'aad' => 'https://login.chinacloudapi.cn' },
16
+ 'AzureUSGovernment' => { 'aad' => 'https://login.microsoftonline.us' }
17
+ }.freeze
18
+
19
+ class OutputConfiguration
20
+ def initialize(opts = {})
21
+ # Initialize configuration options and logger
22
+ @logger_path = opts[:logger_path]
23
+ @logger = initialize_logger
24
+ @client_app_id = opts[:client_app_id]
25
+ @client_app_secret = opts[:client_app_secret]
26
+ @tenant_id = opts[:tenant_id]
27
+ @kusto_endpoint = opts[:kusto_endpoint]
28
+ @database_name = opts[:database_name]
29
+ @table_name = opts[:table_name]
30
+ @azure_cloud = opts[:azure_cloud] || 'AzureCloud'
31
+ @managed_identity_client_id = opts[:managed_identity_client_id]
32
+ @azure_clouds = AZURE_CLOUDS
33
+ @auth_type = opts[:auth_type] || 'aad'
34
+ @workload_identity_client_id = opts[:workload_identity_client_id]
35
+ @workload_identity_tenant_id = opts[:workload_identity_tenant_id]
36
+ @workload_identity_token_file_path = opts[:workload_identity_token_file_path]
37
+ validate_configuration
38
+ end
39
+
40
+ def validate_configuration
41
+ # Validate configuration based on authentication method
42
+ case @auth_type&.downcase
43
+ when 'aad'
44
+ validate_aad_config
45
+ when 'user_managed_identity', 'system_managed_identity', 'azcli'
46
+ validate_base_config
47
+ when 'workload_identity'
48
+ validate_workload_identity_config
49
+ else
50
+ raise ArgumentError, "Unknown auth_type: #{@auth_type}"
51
+ end
52
+ validate_azure_cloud
53
+ true
54
+ end
55
+
56
+ def print_missing_parameter_message_and_raise(param_name)
57
+ # Print error and raise if a required parameter is missing
58
+ @logger.error(
59
+ "Missing a required setting for the Kusto output plugin configuration:\n" \
60
+ "output {\nkusto {\n#{param_name} => # SETTING MISSING\n ...\n}\n}\n"
61
+ )
62
+ raise ArgumentError, "The setting #{param_name} is required for Kusto configuration."
63
+ end
64
+
65
+ attr_reader :logger, :client_app_id, :client_app_secret, :tenant_id, :kusto_endpoint, :database_name, :table_name,
66
+ :managed_identity_client_id, :azure_cloud, :auth_type, :workload_identity_client_id,
67
+ :workload_identity_tenant_id, :workload_identity_token_file_path
68
+
69
+ def aad_endpoint
70
+ # Return Azure AD endpoint for selected cloud
71
+ @azure_clouds[@azure_cloud]['aad']
72
+ end
73
+
74
+ private
75
+
76
+ def initialize_logger
77
+ # Use logger_path if provided, otherwise log to stdout
78
+ logger = if @logger_path && !@logger_path.strip.empty?
79
+ Logger.new(@logger_path, 'daily')
80
+ else
81
+ Logger.new($stdout)
82
+ end
83
+ logger.level = Logger::DEBUG
84
+ logger
85
+ end
86
+
87
+ def using_managed_identity?
88
+ val = @managed_identity_client_id.to_s.strip
89
+ !val.empty?
90
+ end
91
+
92
+ def validate_base_config
93
+ # Validate required configs for Managed Identity
94
+ required = {
95
+ 'kusto_endpoint' => @kusto_endpoint,
96
+ 'database_name' => @database_name,
97
+ 'table_name' => @table_name
98
+ }
99
+ check_required_configs(required, %w[kusto_endpoint database_name table_name])
100
+ # No further validation needed for SYSTEM or GUID
101
+ end
102
+
103
+ def validate_aad_config
104
+ # Validate required configs for AAD
105
+ required = aad_required_hash
106
+ check_required_configs(
107
+ required,
108
+ %w[client_app_id client_app_secret tenant_id kusto_endpoint database_name table_name]
109
+ )
110
+ end
111
+
112
+ def validate_workload_identity_config
113
+ # Validate required configs for Workload Identity
114
+ required = {
115
+ 'workload_identity_client_id' => @workload_identity_client_id,
116
+ 'workload_identity_tenant_id' => @workload_identity_tenant_id,
117
+ 'kusto_endpoint' => @kusto_endpoint,
118
+ 'database_name' => @database_name,
119
+ 'table_name' => @table_name
120
+ }
121
+ check_required_configs(required, %w[client_app_id tenant_id kusto_endpoint database_name table_name])
122
+ end
123
+
124
+ def aad_required_hash
125
+ # Return required config hash for AAD
126
+ {
127
+ 'client_app_id' => @client_app_id,
128
+ 'client_app_secret' => @client_app_secret,
129
+ 'tenant_id' => @tenant_id,
130
+ 'kusto_endpoint' => @kusto_endpoint,
131
+ 'database_name' => @database_name,
132
+ 'table_name' => @table_name
133
+ }
134
+ end
135
+
136
+ def check_required_configs(required_configs, names)
137
+ # Check for missing or empty required configs
138
+ required_configs.each do |name, conf|
139
+ print_missing_parameter_message_and_raise(name) if conf.nil?
140
+ end
141
+ return unless required_configs.values.any? { |conf| conf.to_s.strip.empty? }
142
+
143
+ raise ArgumentError,
144
+ "Malformed configuration, the following arguments can not be null or empty. [#{names.join(', ')}]"
145
+ end
146
+
147
+ def validate_azure_cloud
148
+ # Validate that the selected Azure cloud is supported
149
+ return if @azure_clouds.key?(@azure_cloud)
150
+
151
+ raise ArgumentError,
152
+ "The specified Azure cloud #{@azure_cloud} is not supported. Supported clouds are: " \
153
+ "#{@azure_clouds.keys.join(', ')}."
154
+ end
155
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ingester handles uploading data to Azure Blob Storage and sending ingestion messages to Azure Queue for Kusto ingestion.
4
+ #
5
+ # Responsibilities:
6
+ # - Upload data to blob storage
7
+ # - Prepare and send ingestion messages to queue
8
+ # - Handle errors during upload and ingestion
9
+
10
+ require 'uri'
11
+ require 'json'
12
+ require 'securerandom'
13
+ require 'base64'
14
+ require_relative 'client'
15
+ require_relative 'kusto_error_handler'
16
+ require 'logger'
17
+ require 'net/http'
18
+ class Ingester
19
+ # Use a class instance variable instead of a class variable for client cache
20
+ @client_cache = nil
21
+ class << self
22
+ attr_accessor :client_cache
23
+ end
24
+
25
+ def initialize(outconfiguration)
26
+ # Initialize Ingester with configuration and resources
27
+ @client = self.class.client(outconfiguration)
28
+ @resources = @client.resources
29
+ @logger = begin
30
+ outconfiguration.logger
31
+ rescue StandardError
32
+ Logger.new($stdout)
33
+ end
34
+ end
35
+
36
+ def self.client(outconfiguration)
37
+ # Cache and return a Client instance
38
+ self.client_cache ||= Client.new(outconfiguration)
39
+ end
40
+
41
+ def build_uri(container_sas_uri, name)
42
+ # Build a blob URI with SAS token
43
+ base_uri, sas_token = container_sas_uri.split('?', 2)
44
+ base_uri = base_uri.chomp('/')
45
+ "#{base_uri}/#{name}?#{sas_token}"
46
+ end
47
+
48
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
49
+ def upload_to_blob(blob_uri, raw_data, blob_name)
50
+ # Upload raw data to Azure Blob Storage
51
+ uri_str = build_uri(blob_uri, blob_name)
52
+ uri = URI.parse(uri_str)
53
+ blob_size = raw_data.bytesize
54
+ request = Net::HTTP::Put.new(uri)
55
+ request.body = raw_data
56
+ request['x-ms-blob-type'] = 'BlockBlob'
57
+ request['Content-Length'] = blob_size.to_s
58
+
59
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
60
+ http.request(request)
61
+ end
62
+
63
+ unless response.code.to_i.between?(200, 299)
64
+ begin
65
+ error_handler = KustoErrorHandler.new(response.body)
66
+ if error_handler.permanent_error?
67
+ @logger.error("Permanent error while uploading blob: #{error_handler.message}.Blob name: #{blob_name}")
68
+ end
69
+ rescue StandardError => e
70
+ @logger.error("Failed to parse error response with KustoErrorHandler: #{e.message}. Blob name: #{blob_name}")
71
+ end
72
+ raise "Blob upload failed: #{response.code} #{response.message} - #{response.body}"
73
+ end
74
+
75
+ [uri.to_s, blob_size]
76
+ end
77
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
78
+
79
+ # rubocop:disable Metrics/MethodLength
80
+ def prepare_ingestion_message2(db, table, data_uri, blob_size_bytes, identity_token, compression_enabled = true)
81
+ # Prepare the ingestion message for Azure Queue
82
+ additional_props = {
83
+ 'authorizationContext' => identity_token,
84
+ 'format' => 'multijson'
85
+ }
86
+ additional_props['CompressionType'] = 'gzip' if compression_enabled
87
+ {
88
+ 'Id' => SecureRandom.uuid,
89
+ 'BlobPath' => data_uri,
90
+ 'RawDataSize' => blob_size_bytes,
91
+ 'DatabaseName' => db,
92
+ 'TableName' => table,
93
+ 'RetainBlobOnSuccess' => true,
94
+ 'FlushImmediately' => true,
95
+ 'ReportLevel' => 2, # Report both failures and successes
96
+ 'ReportMethod' => 0, # Use Azure Queue for reporting
97
+ 'AdditionalProperties' => additional_props
98
+ }.to_json
99
+ end
100
+ # rubocop:enable Metrics/MethodLength
101
+
102
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
103
+ def post_message_to_queue_http(queue_uri_with_sas, message)
104
+ # Post the ingestion message to Azure Queue
105
+ base_uri, sas_token = queue_uri_with_sas.split('?', 2)
106
+ base_uri = base_uri.chomp('/')
107
+ post_uri = URI("#{base_uri}/messages?#{sas_token}")
108
+ encoded_message = Base64.strict_encode64(message)
109
+ request = Net::HTTP::Post.new(post_uri)
110
+ request['Content-Type'] = 'application/xml'
111
+ request.body = "<QueueMessage><MessageText>#{encoded_message}</MessageText></QueueMessage>"
112
+ response = Net::HTTP.start(post_uri.hostname, post_uri.port, use_ssl: post_uri.scheme == 'https') do |http|
113
+ http.request(request)
114
+ end
115
+ {
116
+ code: response.code,
117
+ message: response.message,
118
+ body: response.body
119
+ }
120
+ end
121
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
122
+
123
+ def upload_data_to_blob_and_queue(raw_data, blob_name, db, table_name, compression_enabled = true)
124
+ # Upload data to blob and send ingestion message to queue
125
+ blob_uri, blob_size_bytes = upload_to_blob(@resources[:blob_sas_uri], raw_data, blob_name)
126
+ message = prepare_ingestion_message2(db, table_name, blob_uri, blob_size_bytes, @resources[:identity_token],
127
+ compression_enabled)
128
+ post_message_to_queue_http(@resources[:queue_sas_uri], message)
129
+ { blob_uri: blob_uri, blob_size_bytes: blob_size_bytes }
130
+ end
131
+
132
+ def token_provider
133
+ # Return the token provider from the client
134
+ @client.token_provider
135
+ end
136
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ # KustoErrorHandler parses and classifies errors returned from Azure Data Explorer (Kusto) API responses.
4
+ # Provides methods for error extraction, classification, and logging.
5
+ class KustoErrorHandler
6
+ attr_reader :message
7
+
8
+ def initialize(error_response)
9
+ # Parse error response and extract message
10
+ @error = parse_error(error_response)
11
+ @message = @error['Message'] || @error['message'] || error_response
12
+ end
13
+
14
+ def permanent_error?
15
+ # Check if error is marked as permanent
16
+ @error && [true, 'true'].include?(@error['@permanent'])
17
+ end
18
+
19
+ # rubocop:disable Metrics/MethodLength
20
+ # Extracts the Kusto error type (code) from an exception or error response
21
+ def self.extract_kusto_error_type(error)
22
+ error_json = parse_error_json(error)
23
+ return error_json['error']['code'] if error_json && error_json['error'] && error_json['error']['code']
24
+
25
+ nil
26
+ end
27
+
28
+ def self.parse_error_json(error)
29
+ # Parse error JSON from string or exception
30
+ if error.is_a?(String)
31
+ begin
32
+ JSON.parse(error)
33
+ rescue StandardError
34
+ nil
35
+ end
36
+ elsif error.respond_to?(:message)
37
+ begin
38
+ JSON.parse(error.message)
39
+ rescue StandardError
40
+ nil
41
+ end
42
+ end
43
+ end
44
+ # rubocop:enable Metrics/MethodLength
45
+
46
+ # Factory method to create a KustoErrorHandler from error type and message
47
+ def self.from_kusto_error_type(error_type, message)
48
+ # You can expand this logic to map error_type to more structured fields if needed
49
+ error_response = { 'error' => { 'code' => error_type, 'message' => message } }.to_json
50
+ new(error_response)
51
+ end
52
+
53
+ # Handles Kusto errors and logs them appropriately
54
+ def self.handle_kusto_error(logger, e, unique_id)
55
+ kusto_error_type = extract_kusto_error_type(e)
56
+ if kusto_error_type
57
+ kusto_error = from_kusto_error_type(kusto_error_type, e.message)
58
+ log_kusto_data_error(logger, kusto_error)
59
+ log_kusto_drop_chunk(logger, kusto_error, unique_id) if kusto_error.is_permanent?
60
+ # Always raise the custom error if present
61
+ raise kusto_error if kusto_error.is_a?(StandardError)
62
+ raise kusto_error unless kusto_error.is_permanent?
63
+
64
+ nil
65
+ else
66
+ log_failed_ingest(logger, unique_id, e)
67
+ raise
68
+ end
69
+ end
70
+
71
+ # Handles errors during try_write and logs them appropriately
72
+ def self.handle_try_write_error(logger, e, chunk_id)
73
+ kusto_error_type = extract_kusto_error_type(e)
74
+ if kusto_error_type
75
+ kusto_error = from_kusto_error_type(kusto_error_type, e.message)
76
+ log_kusto_data_error(logger, kusto_error)
77
+ if kusto_error.is_permanent?
78
+ log_kusto_drop_chunk(logger, kusto_error, chunk_id)
79
+ return nil
80
+ end
81
+ # Always raise the custom error if present
82
+ raise kusto_error if kusto_error.is_a?(StandardError)
83
+ raise kusto_error unless kusto_error.is_permanent?
84
+
85
+ nil
86
+ else
87
+ logger.error(
88
+ "Failed to ingest chunk #{chunk_id}: #{e.full_message}"
89
+ )
90
+ raise
91
+ end
92
+ end
93
+
94
+ # Log details of a Kusto data error
95
+ def self.log_kusto_data_error(logger, kusto_error)
96
+ logger.error(
97
+ "KustoDataError: #{kusto_error.message} " \
98
+ "(Code: #{kusto_error.failure_code}, Reason: #{kusto_error.failure_sub_code}, " \
99
+ "Permanent: #{kusto_error.is_permanent?})"
100
+ )
101
+ end
102
+
103
+ # Log when a chunk is dropped due to a permanent error
104
+ def self.log_kusto_drop_chunk(logger, kusto_error, chunk_id)
105
+ logger.error(
106
+ "Dropping chunk #{chunk_id} due to permanent Kusto error: #{kusto_error.message}"
107
+ )
108
+ end
109
+
110
+ # Log failed ingestion event
111
+ def self.log_failed_ingest(logger, unique_id, e)
112
+ logger.error(
113
+ "Failed to ingest event to Kusto : #{unique_id}\n" \
114
+ "#{e.full_message}"
115
+ )
116
+ end
117
+
118
+ private
119
+
120
+ def parse_error(error_response)
121
+ # Parse error response JSON
122
+ JSON.parse(error_response)
123
+ rescue StandardError
124
+ {}
125
+ end
126
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Provides helper functions for interacting with Kusto ingestion endpoints and running Kusto API queries.
4
+ # Includes endpoint transformation and query execution logic.
5
+
6
+ require 'net/http'
7
+ require 'uri'
8
+ require 'json'
9
+ require 'securerandom'
10
+ require 'base64'
11
+
12
+ def to_ingest_endpoint(data_endpoint)
13
+ # Convert a Kusto data endpoint to its corresponding ingest endpoint
14
+ data_endpoint.sub(%r{^https://}, 'https://ingest-')
15
+ end
16
+
17
+ # Runs a Kusto API query against the specified endpoint.
18
+ # Handles both management and query endpoints, builds request, and parses response.
19
+ def run_kusto_api_query(query, data_endpoint, token_provider, use_ingest_endpoint: false, database_name: nil)
20
+ access_token = token_provider.get_token
21
+ endpoint = use_ingest_endpoint ? to_ingest_endpoint(data_endpoint) : data_endpoint
22
+ path = use_ingest_endpoint ? '/v1/rest/mgmt' : '/v1/rest/query'
23
+ uri = URI("#{endpoint}#{path}")
24
+
25
+ http = Net::HTTP.new(uri.host, uri.port)
26
+ http.use_ssl = true
27
+
28
+ headers = {
29
+ 'Authorization' => "Bearer #{access_token}",
30
+ 'Content-Type' => 'application/json',
31
+ 'Accept' => 'application/json',
32
+ 'x-ms-client-version' => 'Kusto.FluentD:1.0.0'
33
+ }
34
+
35
+ body_hash = { csl: query }
36
+ body_hash[:db] = database_name if database_name
37
+ body = body_hash.to_json
38
+
39
+ request = Net::HTTP::Post.new(uri.request_uri, headers)
40
+ request.body = body
41
+
42
+ response = http.request(request)
43
+ unless response.code.to_i.between?(200, 299)
44
+ # Print error details if query fails
45
+ puts "Kusto query failed with status #{response.code}:"
46
+ puts response.body
47
+ begin
48
+ error_handler = defined?(KustoErrorHandler) ? KustoErrorHandler.new(response.body) : nil
49
+ puts "Permanent Kusto error: #{error_handler.message}" if error_handler&.permanent_error?
50
+ rescue StandardError => e
51
+ puts "Failed to parse error response with KustoErrorHandler: #{e.message}"
52
+ end
53
+ return response
54
+ end
55
+
56
+ begin
57
+ # Parse and return rows from response JSON
58
+ response_json = JSON.parse(response.body)
59
+ tables = response_json['Tables']
60
+ rows = tables && tables[0] && tables[0]['Rows']
61
+ rows || []
62
+ rescue JSON::ParserError => e
63
+ puts "Failed to parse JSON: #{e}"
64
+ puts response.body
65
+ response
66
+ end
67
+ end