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.
- checksums.yaml +7 -0
- data/Gemfile +8 -0
- data/LICENSE +21 -0
- data/README.md +201 -0
- data/lib/fluent/plugin/auth/aad_tokenprovider.rb +105 -0
- data/lib/fluent/plugin/auth/azcli_tokenprovider.rb +51 -0
- data/lib/fluent/plugin/auth/mi_tokenprovider.rb +92 -0
- data/lib/fluent/plugin/auth/tokenprovider_base.rb +57 -0
- data/lib/fluent/plugin/auth/wif_tokenprovider.rb +50 -0
- data/lib/fluent/plugin/client.rb +155 -0
- data/lib/fluent/plugin/conffile.rb +155 -0
- data/lib/fluent/plugin/ingester.rb +136 -0
- data/lib/fluent/plugin/kusto_error_handler.rb +126 -0
- data/lib/fluent/plugin/kusto_query.rb +67 -0
- data/lib/fluent/plugin/out_kusto.rb +423 -0
- data/test/helper.rb +9 -0
- data/test/plugin/test_azcli_tokenprovider.rb +37 -0
- data/test/plugin/test_e2e_kusto.rb +683 -0
- data/test/plugin/test_out_kusto_config.rb +86 -0
- data/test/plugin/test_out_kusto_format.rb +280 -0
- data/test/plugin/test_out_kusto_process.rb +150 -0
- data/test/plugin/test_out_kusto_start.rb +429 -0
- data/test/plugin/test_out_kusto_try_write.rb +382 -0
- data/test/plugin/test_out_kusto_write.rb +370 -0
- metadata +171 -0
@@ -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
|