logstash-input-elasticsearch 4.22.0 → 4.23.0
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 +4 -4
- data/CHANGELOG.md +3 -0
- data/docs/index.asciidoc +122 -4
- data/lib/logstash/inputs/elasticsearch/esql.rb +153 -0
- data/lib/logstash/inputs/elasticsearch.rb +78 -36
- data/logstash-input-elasticsearch.gemspec +1 -1
- data/spec/inputs/elasticsearch_esql_spec.rb +180 -0
- data/spec/inputs/elasticsearch_spec.rb +125 -0
- data/spec/inputs/integration/elasticsearch_esql_spec.rb +150 -0
- metadata +7 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 330a1fd55cb3fa00918a73dcd41b66e63ce81d6fc79dc68f4209385429e588d4
|
4
|
+
data.tar.gz: 5ba0377bcaaa9b428a4a848e32fff5019353ca7f6b3c8bb77944156d05230d6f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8be2dc35edde5b3b83c2c5711941c58c9aa3e45330e3785fef21269af134e13d031e10cdc324cf31e35b6c42a48f215f9ff2e8d58bc3a77fcc0c5a31c2084998
|
7
|
+
data.tar.gz: b0456a0a04f365a34e35d6b8f8040e75a4d9a0f73718a2958f3c87823e5e41a5b04308b8f1710b8d5aa57091015aba273f308c75bd79c61494fecea4baf00d8e
|
data/CHANGELOG.md
CHANGED
data/docs/index.asciidoc
CHANGED
@@ -230,6 +230,110 @@ The next scheduled run:
|
|
230
230
|
* uses {ref}/point-in-time-api.html#point-in-time-api[Point in time (PIT)] + {ref}/paginate-search-results.html#search-after[Search after] to paginate through all the data, and
|
231
231
|
* updates the value of the field at the end of the pagination.
|
232
232
|
|
233
|
+
[id="plugins-{type}s-{plugin}-esql"]
|
234
|
+
==== {esql} support
|
235
|
+
|
236
|
+
.Technical Preview
|
237
|
+
****
|
238
|
+
The {esql} feature that allows using ES|QL queries with this plugin is in Technical Preview.
|
239
|
+
Configuration options and implementation details are subject to change in minor releases without being preceded by deprecation warnings.
|
240
|
+
****
|
241
|
+
|
242
|
+
{es} Query Language ({esql}) provides a SQL-like interface for querying your {es} data.
|
243
|
+
|
244
|
+
To use {esql}, this plugin needs to be installed in {ls} 8.17.4 or newer, and must be connected to {es} 8.11 or newer.
|
245
|
+
|
246
|
+
To configure {esql} query in the plugin, set the `query_type` to `esql` and provide your {esql} query in the `query` parameter.
|
247
|
+
|
248
|
+
IMPORTANT: {esql} is evolving and may still have limitations with regard to result size or supported field types. We recommend understanding https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-limitations.html[ES|QL current limitations] before using it in production environments.
|
249
|
+
|
250
|
+
The following is a basic scheduled {esql} query that runs hourly:
|
251
|
+
[source, ruby]
|
252
|
+
input {
|
253
|
+
elasticsearch {
|
254
|
+
id => hourly_cron_job
|
255
|
+
hosts => [ 'https://..']
|
256
|
+
api_key => '....'
|
257
|
+
query_type => 'esql'
|
258
|
+
query => '
|
259
|
+
FROM food-index
|
260
|
+
| WHERE spicy_level = "hot" AND @timestamp > NOW() - 1 hour
|
261
|
+
| LIMIT 500
|
262
|
+
'
|
263
|
+
schedule => '0 * * * *' # every hour at min 0
|
264
|
+
}
|
265
|
+
}
|
266
|
+
|
267
|
+
Set `config.support_escapes: true` in `logstash.yml` if you need to escape special chars in the query.
|
268
|
+
|
269
|
+
NOTE: With {esql} query, {ls} doesn't generate `event.original`.
|
270
|
+
|
271
|
+
[id="plugins-{type}s-{plugin}-esql-event-mapping"]
|
272
|
+
===== Mapping {esql} result to {ls} event
|
273
|
+
{esql} returns query results in a structured tabular format, where data is organized into _columns_ (fields) and _values_ (entries).
|
274
|
+
The plugin maps each value entry to an event, populating corresponding fields.
|
275
|
+
For example, a query might produce a table like:
|
276
|
+
|
277
|
+
[cols="2,1,1,1,2",options="header"]
|
278
|
+
|===
|
279
|
+
|`timestamp` |`user_id` | `action` | `status.code` | `status.desc`
|
280
|
+
|
281
|
+
|2025-04-10T12:00:00 |123 |login |200 | Success
|
282
|
+
|2025-04-10T12:05:00 |456 |purchase |403 | Forbidden (unauthorized user)
|
283
|
+
|===
|
284
|
+
|
285
|
+
For this case, the plugin emits two events look like
|
286
|
+
[source, json]
|
287
|
+
[
|
288
|
+
{
|
289
|
+
"timestamp": "2025-04-10T12:00:00",
|
290
|
+
"user_id": 123,
|
291
|
+
"action": "login",
|
292
|
+
"status": {
|
293
|
+
"code": 200,
|
294
|
+
"desc": "Success"
|
295
|
+
}
|
296
|
+
},
|
297
|
+
{
|
298
|
+
"timestamp": "2025-04-10T12:05:00",
|
299
|
+
"user_id": 456,
|
300
|
+
"action": "purchase",
|
301
|
+
"status": {
|
302
|
+
"code": 403,
|
303
|
+
"desc": "Forbidden (unauthorized user)"
|
304
|
+
}
|
305
|
+
}
|
306
|
+
]
|
307
|
+
|
308
|
+
NOTE: If your index has a mapping with sub-objects where `status.code` and `status.desc` actually dotted fields, they appear in {ls} events as a nested structure.
|
309
|
+
|
310
|
+
[id="plugins-{type}s-{plugin}-esql-multifields"]
|
311
|
+
===== Conflict on multi-fields
|
312
|
+
|
313
|
+
{esql} query fetches all parent and sub-fields fields if your {es} index has https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/multi-fields[multi-fields] or https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/subobjects[subobjects].
|
314
|
+
Since {ls} events cannot contain parent field's concrete value and sub-field values together, the plugin ignores sub-fields with warning and includes parent.
|
315
|
+
We recommend using the `RENAME` (or `DROP` to avoid warnings) keyword in your {esql} query explicitly rename the fields to include sub-fields into the event.
|
316
|
+
|
317
|
+
This a common occurrence if your template or mapping follows the pattern of always indexing strings as "text" (`field`) + " keyword" (`field.keyword`) multi-field.
|
318
|
+
In this case it's recommended to do `KEEP field` if the string is identical and there is only one subfield as the engine will optimize and retrieve the keyword, otherwise you can do `KEEP field.keyword | RENAME field.keyword as field`.
|
319
|
+
|
320
|
+
To illustrate the situation with example, assuming your mapping has a time `time` field with `time.min` and `time.max` sub-fields as following:
|
321
|
+
[source, ruby]
|
322
|
+
"properties": {
|
323
|
+
"time": { "type": "long" },
|
324
|
+
"time.min": { "type": "long" },
|
325
|
+
"time.max": { "type": "long" }
|
326
|
+
}
|
327
|
+
|
328
|
+
The {esql} result will contain all three fields but the plugin cannot map them into {ls} event.
|
329
|
+
To avoid this, you can use the `RENAME` keyword to rename the `time` parent field to get all three fields with unique fields.
|
330
|
+
[source, ruby]
|
331
|
+
...
|
332
|
+
query => 'FROM my-index | RENAME time AS time.current'
|
333
|
+
...
|
334
|
+
|
335
|
+
For comprehensive {esql} syntax reference and best practices, see the https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-syntax.html[{esql} documentation].
|
336
|
+
|
233
337
|
[id="plugins-{type}s-{plugin}-options"]
|
234
338
|
==== Elasticsearch Input configuration options
|
235
339
|
|
@@ -254,6 +358,7 @@ This plugin supports the following configuration options plus the <<plugins-{typ
|
|
254
358
|
| <<plugins-{type}s-{plugin}-password>> |<<password,password>>|No
|
255
359
|
| <<plugins-{type}s-{plugin}-proxy>> |<<uri,uri>>|No
|
256
360
|
| <<plugins-{type}s-{plugin}-query>> |<<string,string>>|No
|
361
|
+
| <<plugins-{type}s-{plugin}-query_type>> |<<string,string>>, one of `["dsl","esql"]`|No
|
257
362
|
| <<plugins-{type}s-{plugin}-response_type>> |<<string,string>>, one of `["hits","aggregations"]`|No
|
258
363
|
| <<plugins-{type}s-{plugin}-request_timeout_seconds>> | <<number,number>>|No
|
259
364
|
| <<plugins-{type}s-{plugin}-schedule>> |<<string,string>>|No
|
@@ -495,22 +600,35 @@ environment variables e.g. `proxy => '${LS_PROXY:}'`.
|
|
495
600
|
* Value type is <<string,string>>
|
496
601
|
* Default value is `'{ "sort": [ "_doc" ] }'`
|
497
602
|
|
498
|
-
The query to be executed.
|
499
|
-
|
603
|
+
The query to be executed.
|
604
|
+
Accepted query shape is DSL or {esql} (when `query_type => 'esql'`).
|
605
|
+
Read the {ref}/query-dsl.html[{es} query DSL documentation] or {ref}/esql.html[{esql} documentation] for more information.
|
500
606
|
|
501
607
|
When <<plugins-{type}s-{plugin}-search_api>> resolves to `search_after` and the query does not specify `sort`,
|
502
608
|
the default sort `'{ "sort": { "_shard_doc": "asc" } }'` will be added to the query. Please refer to the {ref}/paginate-search-results.html#search-after[Elasticsearch search_after] parameter to know more.
|
503
609
|
|
610
|
+
[id="plugins-{type}s-{plugin}-query_type"]
|
611
|
+
===== `query_type`
|
612
|
+
|
613
|
+
* Value can be `dsl` or `esql`
|
614
|
+
* Default value is `dsl`
|
615
|
+
|
616
|
+
Defines the <<plugins-{type}s-{plugin}-query>> shape.
|
617
|
+
When `dsl`, the query shape must be valid {es} JSON-style string.
|
618
|
+
When `esql`, the query shape must be a valid {esql} string and `index`, `size`, `slices`, `search_api`, `docinfo`, `docinfo_target`, `docinfo_fields`, `response_type` and `tracking_field` parameters are not allowed.
|
619
|
+
|
504
620
|
[id="plugins-{type}s-{plugin}-response_type"]
|
505
621
|
===== `response_type`
|
506
622
|
|
507
|
-
* Value can be any of: `hits`, `aggregations`
|
623
|
+
* Value can be any of: `hits`, `aggregations`, `esql`
|
508
624
|
* Default value is `hits`
|
509
625
|
|
510
626
|
Which part of the result to transform into Logstash events when processing the
|
511
627
|
response from the query.
|
628
|
+
|
512
629
|
The default `hits` will generate one event per returned document (i.e. "hit").
|
513
|
-
|
630
|
+
|
631
|
+
When set to `aggregations`, a single {ls} event will be generated with the
|
514
632
|
contents of the `aggregations` object of the query's response. In this case the
|
515
633
|
`hits` object will be ignored. The parameter `size` will be always be set to
|
516
634
|
0 regardless of the default or user-defined value set in this plugin.
|
@@ -0,0 +1,153 @@
|
|
1
|
+
require 'logstash/helpers/loggable_try'
|
2
|
+
|
3
|
+
module LogStash
|
4
|
+
module Inputs
|
5
|
+
class Elasticsearch
|
6
|
+
class Esql
|
7
|
+
include LogStash::Util::Loggable
|
8
|
+
|
9
|
+
ESQL_JOB = "ES|QL job"
|
10
|
+
|
11
|
+
ESQL_PARSERS_BY_TYPE = Hash.new(lambda { |x| x }).merge(
|
12
|
+
'date' => ->(value) { value && LogStash::Timestamp.new(value) },
|
13
|
+
)
|
14
|
+
|
15
|
+
# Initialize the ESQL query executor
|
16
|
+
# @param client [Elasticsearch::Client] The Elasticsearch client instance
|
17
|
+
# @param plugin [LogStash::Inputs::Elasticsearch] The parent plugin instance
|
18
|
+
def initialize(client, plugin)
|
19
|
+
@client = client
|
20
|
+
@event_decorator = plugin.method(:decorate_event)
|
21
|
+
@retries = plugin.params["retries"]
|
22
|
+
|
23
|
+
target_field = plugin.params["target"]
|
24
|
+
if target_field
|
25
|
+
def self.apply_target(path); "[#{target_field}][#{path}]"; end
|
26
|
+
else
|
27
|
+
def self.apply_target(path); path; end
|
28
|
+
end
|
29
|
+
|
30
|
+
@query = plugin.params["query"]
|
31
|
+
unless @query.include?('METADATA')
|
32
|
+
logger.info("`METADATA` not found the query. `_id`, `_version` and `_index` will not be available in the result", {:query => @query})
|
33
|
+
end
|
34
|
+
logger.debug("ES|QL executor initialized with", {:query => @query})
|
35
|
+
end
|
36
|
+
|
37
|
+
# Execute the ESQL query and process results
|
38
|
+
# @param output_queue [Queue] The queue to push processed events to
|
39
|
+
# @param query A query (to obey interface definition)
|
40
|
+
def do_run(output_queue, query)
|
41
|
+
logger.info("ES|QL executor has started")
|
42
|
+
response = retryable(ESQL_JOB) do
|
43
|
+
@client.esql.query({ body: { query: @query }, format: 'json', drop_null_columns: true })
|
44
|
+
end
|
45
|
+
# retriable already printed error details
|
46
|
+
return if response == false
|
47
|
+
|
48
|
+
if response&.headers&.dig("warning")
|
49
|
+
logger.warn("ES|QL executor received warning", {:warning_message => response.headers["warning"]})
|
50
|
+
end
|
51
|
+
columns = response['columns']&.freeze
|
52
|
+
values = response['values']&.freeze
|
53
|
+
logger.debug("ES|QL query response size: #{values&.size}")
|
54
|
+
|
55
|
+
process_response(columns, values, output_queue) if columns && values
|
56
|
+
end
|
57
|
+
|
58
|
+
# Execute a retryable operation with proper error handling
|
59
|
+
# @param job_name [String] Name of the job for logging purposes
|
60
|
+
# @yield The block to execute
|
61
|
+
# @return [Boolean] true if successful, false otherwise
|
62
|
+
def retryable(job_name, &block)
|
63
|
+
stud_try = ::LogStash::Helpers::LoggableTry.new(logger, job_name)
|
64
|
+
stud_try.try((@retries + 1).times) { yield }
|
65
|
+
rescue => e
|
66
|
+
error_details = {:message => e.message, :cause => e.cause}
|
67
|
+
error_details[:backtrace] = e.backtrace if logger.debug?
|
68
|
+
logger.error("#{job_name} failed with ", error_details)
|
69
|
+
false
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
# Process the ESQL response and push events to the output queue
|
75
|
+
# @param columns [Array[Hash]] The ESQL query response columns
|
76
|
+
# @param values [Array[Array]] The ESQL query response hits
|
77
|
+
# @param output_queue [Queue] The queue to push processed events to
|
78
|
+
def process_response(columns, values, output_queue)
|
79
|
+
column_specs = columns.map { |column| ColumnSpec.new(column) }
|
80
|
+
sub_element_mark_map = mark_sub_elements(column_specs)
|
81
|
+
multi_fields = sub_element_mark_map.filter_map { |key, val| key.name if val == true }
|
82
|
+
logger.warn("Multi-fields found in ES|QL result and they will not be available in the event. Please use `RENAME` command if you want to include them.", { :detected_multi_fields => multi_fields }) if multi_fields.any?
|
83
|
+
|
84
|
+
values.each do |row|
|
85
|
+
event = column_specs.zip(row).each_with_object(LogStash::Event.new) do |(column, value), event|
|
86
|
+
# `unless value.nil?` is a part of `drop_null_columns` that if some of columns' values are not `nil`, `nil` values appear
|
87
|
+
# we should continuously filter out them to achieve full `drop_null_columns` on each individual row (ideal `LIMIT 1` result)
|
88
|
+
# we also exclude sub-elements of main field
|
89
|
+
if value && sub_element_mark_map[column] == false
|
90
|
+
field_reference = apply_target(column.field_reference)
|
91
|
+
event.set(field_reference, ESQL_PARSERS_BY_TYPE[column.type].call(value))
|
92
|
+
end
|
93
|
+
end
|
94
|
+
@event_decorator.call(event)
|
95
|
+
output_queue << event
|
96
|
+
rescue => e
|
97
|
+
# if event creation fails with whatever reason, inform user and tag with failure and return entry as it is
|
98
|
+
logger.warn("Event creation error, ", message: e.message, exception: e.class, data: { "columns" => columns, "values" => [row] })
|
99
|
+
failed_event = LogStash::Event.new("columns" => columns, "values" => [row], "tags" => ['_elasticsearch_input_failure'])
|
100
|
+
output_queue << failed_event
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# Determines whether each column in a collection is a nested sub-element (example "user.age")
|
105
|
+
# of another column in the same collection (example "user").
|
106
|
+
#
|
107
|
+
# @param columns [Array<ColumnSpec>] An array of objects with a `name` attribute representing field paths.
|
108
|
+
# @return [Hash<ColumnSpec, Boolean>] A hash mapping each column to `true` if it is a sub-element of another field, `false` otherwise.
|
109
|
+
# Time complexity: (O(NlogN+N*K)) where K is the number of conflict depth
|
110
|
+
# without (`prefix_set`) memoization, it would be O(N^2)
|
111
|
+
def mark_sub_elements(columns)
|
112
|
+
# Sort columns by name length (ascending)
|
113
|
+
sorted_columns = columns.sort_by { |c| c.name.length }
|
114
|
+
prefix_set = Set.new # memoization set
|
115
|
+
|
116
|
+
sorted_columns.each_with_object({}) do |column, memo|
|
117
|
+
# Split the column name into parts (e.g., "user.profile.age" → ["user", "profile", "age"])
|
118
|
+
parts = column.name.split('.')
|
119
|
+
|
120
|
+
# Generate all possible parent prefixes (e.g., "user", "user.profile")
|
121
|
+
# and check if any parent prefix exists in the set
|
122
|
+
parent_prefixes = (0...parts.size - 1).map { |i| parts[0..i].join('.') }
|
123
|
+
memo[column] = parent_prefixes.any? { |prefix| prefix_set.include?(prefix) }
|
124
|
+
prefix_set.add(column.name)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
# Class representing a column specification in the ESQL response['columns']
|
130
|
+
# The class's main purpose is to provide a structure for the event key
|
131
|
+
# columns is an array with `name` and `type` pair (example: `{"name"=>"@timestamp", "type"=>"date"}`)
|
132
|
+
# @attr_reader :name [String] The name of the column
|
133
|
+
# @attr_reader :type [String] The type of the column
|
134
|
+
class ColumnSpec
|
135
|
+
attr_reader :name, :type
|
136
|
+
|
137
|
+
def initialize(spec)
|
138
|
+
@name = isolate(spec.fetch('name'))
|
139
|
+
@type = isolate(spec.fetch('type'))
|
140
|
+
end
|
141
|
+
|
142
|
+
def field_reference
|
143
|
+
@_field_reference ||= '[' + name.gsub('.', '][') + ']'
|
144
|
+
end
|
145
|
+
|
146
|
+
private
|
147
|
+
def isolate(value)
|
148
|
+
value.frozen? ? value : value.clone.freeze
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
@@ -74,6 +74,7 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
74
74
|
require 'logstash/inputs/elasticsearch/paginated_search'
|
75
75
|
require 'logstash/inputs/elasticsearch/aggregation'
|
76
76
|
require 'logstash/inputs/elasticsearch/cursor_tracker'
|
77
|
+
require 'logstash/inputs/elasticsearch/esql'
|
77
78
|
|
78
79
|
include LogStash::PluginMixins::ECSCompatibilitySupport(:disabled, :v1, :v8 => :v1)
|
79
80
|
include LogStash::PluginMixins::ECSCompatibilitySupport::TargetCheck
|
@@ -96,15 +97,21 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
96
97
|
# The index or alias to search.
|
97
98
|
config :index, :validate => :string, :default => "logstash-*"
|
98
99
|
|
99
|
-
#
|
100
|
-
|
101
|
-
|
100
|
+
# A type of Elasticsearch query, provided by @query. This will validate query shape and other params.
|
101
|
+
config :query_type, :validate => %w[dsl esql], :default => 'dsl'
|
102
|
+
|
103
|
+
# The query to be executed. DSL or ES|QL (when `query_type => 'esql'`) query shape is accepted.
|
104
|
+
# Read the following documentations for more info
|
105
|
+
# Query DSL: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html
|
106
|
+
# ES|QL: https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html
|
102
107
|
config :query, :validate => :string, :default => '{ "sort": [ "_doc" ] }'
|
103
108
|
|
104
|
-
# This allows you to
|
105
|
-
# where
|
106
|
-
#
|
107
|
-
|
109
|
+
# This allows you to specify the DSL response type: one of [hits, aggregations]
|
110
|
+
# where
|
111
|
+
# hits: normal search request
|
112
|
+
# aggregations: aggregation request
|
113
|
+
# Note that this param is invalid when `query_type => 'esql'`, ES|QL response shape is always a tabular format
|
114
|
+
config :response_type, :validate => %w[hits aggregations], :default => 'hits'
|
108
115
|
|
109
116
|
# This allows you to set the maximum number of hits returned per scroll.
|
110
117
|
config :size, :validate => :number, :default => 1000
|
@@ -293,6 +300,9 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
293
300
|
DEFAULT_EAV_HEADER = { "Elastic-Api-Version" => "2023-10-31" }.freeze
|
294
301
|
INTERNAL_ORIGIN_HEADER = { 'x-elastic-product-origin' => 'logstash-input-elasticsearch'}.freeze
|
295
302
|
|
303
|
+
LS_ESQL_SUPPORT_VERSION = "8.17.4" # the version started using elasticsearch-ruby v8
|
304
|
+
ES_ESQL_SUPPORT_VERSION = "8.11.0"
|
305
|
+
|
296
306
|
def initialize(params={})
|
297
307
|
super(params)
|
298
308
|
|
@@ -309,10 +319,17 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
309
319
|
fill_hosts_from_cloud_id
|
310
320
|
setup_ssl_params!
|
311
321
|
|
312
|
-
@
|
313
|
-
|
314
|
-
|
315
|
-
|
322
|
+
if @query_type == 'esql'
|
323
|
+
validate_ls_version_for_esql_support!
|
324
|
+
validate_esql_query!
|
325
|
+
not_allowed_options = original_params.keys & %w(index size slices search_api docinfo docinfo_target docinfo_fields response_type tracking_field)
|
326
|
+
raise(LogStash::ConfigurationError, "Configured #{not_allowed_options} params are not allowed while using ES|QL query") if not_allowed_options&.size > 1
|
327
|
+
else
|
328
|
+
@base_query = LogStash::Json.load(@query)
|
329
|
+
if @slices
|
330
|
+
@base_query.include?('slice') && fail(LogStash::ConfigurationError, "Elasticsearch Input Plugin's `query` option cannot specify specific `slice` when configured to manage parallel slices with `slices` option")
|
331
|
+
@slices < 1 && fail(LogStash::ConfigurationError, "Elasticsearch Input Plugin's `slices` option must be greater than zero, got `#{@slices}`")
|
332
|
+
end
|
316
333
|
end
|
317
334
|
|
318
335
|
@retries < 0 && fail(LogStash::ConfigurationError, "Elasticsearch Input Plugin's `retries` option must be equal or greater than zero, got `#{@retries}`")
|
@@ -348,11 +365,13 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
348
365
|
|
349
366
|
test_connection!
|
350
367
|
|
368
|
+
validate_es_for_esql_support!
|
369
|
+
|
351
370
|
setup_serverless
|
352
371
|
|
353
372
|
setup_search_api
|
354
373
|
|
355
|
-
|
374
|
+
@query_executor = create_query_executor
|
356
375
|
|
357
376
|
setup_cursor_tracker
|
358
377
|
|
@@ -370,16 +389,6 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
370
389
|
end
|
371
390
|
end
|
372
391
|
|
373
|
-
def get_query_object
|
374
|
-
if @cursor_tracker
|
375
|
-
query = @cursor_tracker.inject_cursor(@query)
|
376
|
-
@logger.debug("new query is #{query}")
|
377
|
-
else
|
378
|
-
query = @query
|
379
|
-
end
|
380
|
-
LogStash::Json.load(query)
|
381
|
-
end
|
382
|
-
|
383
392
|
##
|
384
393
|
# This can be called externally from the query_executor
|
385
394
|
public
|
@@ -390,6 +399,23 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
390
399
|
record_last_value(event)
|
391
400
|
end
|
392
401
|
|
402
|
+
def decorate_event(event)
|
403
|
+
decorate(event)
|
404
|
+
end
|
405
|
+
|
406
|
+
private
|
407
|
+
|
408
|
+
def get_query_object
|
409
|
+
return @query if @query_type == 'esql'
|
410
|
+
if @cursor_tracker
|
411
|
+
query = @cursor_tracker.inject_cursor(@query)
|
412
|
+
@logger.debug("new query is #{query}")
|
413
|
+
else
|
414
|
+
query = @query
|
415
|
+
end
|
416
|
+
LogStash::Json.load(query)
|
417
|
+
end
|
418
|
+
|
393
419
|
def record_last_value(event)
|
394
420
|
@cursor_tracker.record_last_value(event) if @tracking_field
|
395
421
|
end
|
@@ -421,8 +447,6 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
421
447
|
event.set(@docinfo_target, docinfo_target)
|
422
448
|
end
|
423
449
|
|
424
|
-
private
|
425
|
-
|
426
450
|
def hosts_default?(hosts)
|
427
451
|
hosts.nil? || ( hosts.is_a?(Array) && hosts.empty? )
|
428
452
|
end
|
@@ -700,18 +724,16 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
700
724
|
|
701
725
|
end
|
702
726
|
|
703
|
-
def
|
704
|
-
@
|
705
|
-
|
706
|
-
|
707
|
-
|
708
|
-
|
709
|
-
|
710
|
-
|
711
|
-
|
712
|
-
|
713
|
-
LogStash::Inputs::Elasticsearch::Aggregation.new(@client, self)
|
714
|
-
end
|
727
|
+
def create_query_executor
|
728
|
+
return LogStash::Inputs::Elasticsearch::Esql.new(@client, self) if @query_type == 'esql'
|
729
|
+
|
730
|
+
# DSL query executor
|
731
|
+
return LogStash::Inputs::Elasticsearch::Aggregation.new(@client, self) if @response_type == 'aggregations'
|
732
|
+
# response_type is hits, executor can be search_after or scroll type
|
733
|
+
return LogStash::Inputs::Elasticsearch::SearchAfter.new(@client, self) if @resolved_search_api == "search_after"
|
734
|
+
|
735
|
+
logger.warn("scroll API is no longer recommended for pagination. Consider using search_after instead.") if es_major_version >= 8
|
736
|
+
LogStash::Inputs::Elasticsearch::Scroll.new(@client, self)
|
715
737
|
end
|
716
738
|
|
717
739
|
def setup_cursor_tracker
|
@@ -750,6 +772,26 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
750
772
|
::Elastic::Transport::Transport::HTTP::Manticore
|
751
773
|
end
|
752
774
|
|
775
|
+
def validate_ls_version_for_esql_support!
|
776
|
+
if Gem::Version.create(LOGSTASH_VERSION) < Gem::Version.create(LS_ESQL_SUPPORT_VERSION)
|
777
|
+
fail("Current version of Logstash does not include Elasticsearch client which supports ES|QL. Please upgrade Logstash to at least #{LS_ESQL_SUPPORT_VERSION}")
|
778
|
+
end
|
779
|
+
end
|
780
|
+
|
781
|
+
def validate_esql_query!
|
782
|
+
fail(LogStash::ConfigurationError, "`query` cannot be empty") if @query.strip.empty?
|
783
|
+
source_commands = %w[FROM ROW SHOW]
|
784
|
+
contains_source_command = source_commands.any? { |source_command| @query.strip.start_with?(source_command) }
|
785
|
+
fail(LogStash::ConfigurationError, "`query` needs to start with any of #{source_commands}") unless contains_source_command
|
786
|
+
end
|
787
|
+
|
788
|
+
def validate_es_for_esql_support!
|
789
|
+
return unless @query_type == 'esql'
|
790
|
+
# make sure connected ES supports ES|QL (8.11+)
|
791
|
+
es_supports_esql = Gem::Version.create(es_version) >= Gem::Version.create(ES_ESQL_SUPPORT_VERSION)
|
792
|
+
fail("Connected Elasticsearch #{es_version} version does not supports ES|QL. ES|QL feature requires at least Elasticsearch #{ES_ESQL_SUPPORT_VERSION} version.") unless es_supports_esql
|
793
|
+
end
|
794
|
+
|
753
795
|
module URIOrEmptyValidator
|
754
796
|
##
|
755
797
|
# @override to provide :uri_or_empty validator
|
@@ -1,7 +1,7 @@
|
|
1
1
|
Gem::Specification.new do |s|
|
2
2
|
|
3
3
|
s.name = 'logstash-input-elasticsearch'
|
4
|
-
s.version = '4.
|
4
|
+
s.version = '4.23.0'
|
5
5
|
s.licenses = ['Apache License (2.0)']
|
6
6
|
s.summary = "Reads query results from an Elasticsearch cluster"
|
7
7
|
s.description = "This gem is a Logstash plugin required to be installed on top of the Logstash core pipeline using $LS_HOME/bin/logstash-plugin install gemname. This gem is not a stand-alone program"
|
@@ -0,0 +1,180 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require "logstash/devutils/rspec/spec_helper"
|
3
|
+
require "logstash/inputs/elasticsearch"
|
4
|
+
require "elasticsearch"
|
5
|
+
|
6
|
+
describe LogStash::Inputs::Elasticsearch::Esql do
|
7
|
+
let(:client) { instance_double(Elasticsearch::Client) }
|
8
|
+
let(:esql_client) { double("esql-client") }
|
9
|
+
|
10
|
+
let(:plugin) { instance_double(LogStash::Inputs::Elasticsearch, params: plugin_config, decorate_event: nil) }
|
11
|
+
let(:plugin_config) do
|
12
|
+
{
|
13
|
+
"query" => "FROM test-index | STATS count() BY field",
|
14
|
+
"retries" => 3
|
15
|
+
}
|
16
|
+
end
|
17
|
+
let(:esql_executor) { described_class.new(client, plugin) }
|
18
|
+
|
19
|
+
describe "#initialization" do
|
20
|
+
it "sets up the ESQL client with correct parameters" do
|
21
|
+
expect(esql_executor.instance_variable_get(:@query)).to eq(plugin_config["query"])
|
22
|
+
expect(esql_executor.instance_variable_get(:@retries)).to eq(plugin_config["retries"])
|
23
|
+
expect(esql_executor.instance_variable_get(:@target_field)).to eq(nil)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
describe "#execution" do
|
28
|
+
let(:output_queue) { Queue.new }
|
29
|
+
|
30
|
+
context "when faces error while retrying" do
|
31
|
+
it "retries the given block the specified number of times" do
|
32
|
+
attempts = 0
|
33
|
+
result = esql_executor.retryable("Test Job") do
|
34
|
+
attempts += 1
|
35
|
+
raise StandardError if attempts < 3
|
36
|
+
"success"
|
37
|
+
end
|
38
|
+
expect(attempts).to eq(3)
|
39
|
+
expect(result).to eq("success")
|
40
|
+
end
|
41
|
+
|
42
|
+
it "returns false if the block fails all attempts" do
|
43
|
+
result = esql_executor.retryable("Test Job") do
|
44
|
+
raise StandardError
|
45
|
+
end
|
46
|
+
expect(result).to eq(false)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
context "when executing chain of processes" do
|
51
|
+
let(:response) { { 'values' => [%w[foo bar]], 'columns' => [{ 'name' => 'a.b.1.d', 'type' => 'keyword' },
|
52
|
+
{ 'name' => 'h_g.k$l.m.0', 'type' => 'keyword' }] } }
|
53
|
+
|
54
|
+
before do
|
55
|
+
allow(esql_executor).to receive(:retryable).and_yield
|
56
|
+
allow(client).to receive_message_chain(:esql, :query).and_return(response)
|
57
|
+
end
|
58
|
+
|
59
|
+
it "executes the ESQL query and processes the results" do
|
60
|
+
allow(response).to receive(:headers).and_return({})
|
61
|
+
esql_executor.do_run(output_queue, plugin_config["query"])
|
62
|
+
expect(output_queue.size).to eq(1)
|
63
|
+
|
64
|
+
event = output_queue.pop
|
65
|
+
expect(event.get('[a][b][1][d]')).to eq('foo')
|
66
|
+
expect(event.get('[h_g][k$l][m][0]')).to eq('bar')
|
67
|
+
end
|
68
|
+
|
69
|
+
it "logs a warning if the response contains a warning header" do
|
70
|
+
allow(response).to receive(:headers).and_return({ "warning" => "some warning" })
|
71
|
+
expect(esql_executor.logger).to receive(:warn).with("ES|QL executor received warning", { :warning_message => "some warning" })
|
72
|
+
esql_executor.do_run(output_queue, plugin_config["query"])
|
73
|
+
end
|
74
|
+
|
75
|
+
it "does not log a warning if the response does not contain a warning header" do
|
76
|
+
allow(response).to receive(:headers).and_return({})
|
77
|
+
expect(esql_executor.logger).not_to receive(:warn)
|
78
|
+
esql_executor.do_run(output_queue, plugin_config["query"])
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
describe "multiple rows in the result" do
|
83
|
+
let(:response) { { 'values' => rows, 'columns' => [{ 'name' => 'key.1', 'type' => 'keyword' },
|
84
|
+
{ 'name' => 'key.2', 'type' => 'keyword' }] } }
|
85
|
+
|
86
|
+
before do
|
87
|
+
allow(esql_executor).to receive(:retryable).and_yield
|
88
|
+
allow(client).to receive_message_chain(:esql, :query).and_return(response)
|
89
|
+
allow(response).to receive(:headers).and_return({})
|
90
|
+
end
|
91
|
+
|
92
|
+
context "when mapping" do
|
93
|
+
let(:rows) { [%w[foo bar], %w[hello world]] }
|
94
|
+
|
95
|
+
it "1:1 maps rows to events" do
|
96
|
+
esql_executor.do_run(output_queue, plugin_config["query"])
|
97
|
+
expect(output_queue.size).to eq(2)
|
98
|
+
|
99
|
+
event_1 = output_queue.pop
|
100
|
+
expect(event_1.get('[key][1]')).to eq('foo')
|
101
|
+
expect(event_1.get('[key][2]')).to eq('bar')
|
102
|
+
|
103
|
+
event_2 = output_queue.pop
|
104
|
+
expect(event_2.get('[key][1]')).to eq('hello')
|
105
|
+
expect(event_2.get('[key][2]')).to eq('world')
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
context "when partial nil values appear" do
|
110
|
+
let(:rows) { [[nil, "bar"], ["hello", nil]] }
|
111
|
+
|
112
|
+
it "ignores the nil values" do
|
113
|
+
esql_executor.do_run(output_queue, plugin_config["query"])
|
114
|
+
expect(output_queue.size).to eq(2)
|
115
|
+
|
116
|
+
event_1 = output_queue.pop
|
117
|
+
expect(event_1.get('[key][1]')).to eq(nil)
|
118
|
+
expect(event_1.get('[key][2]')).to eq('bar')
|
119
|
+
|
120
|
+
event_2 = output_queue.pop
|
121
|
+
expect(event_2.get('[key][1]')).to eq('hello')
|
122
|
+
expect(event_2.get('[key][2]')).to eq(nil)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
context "when sub-elements occur in the result" do
|
128
|
+
let(:response) { {
|
129
|
+
'values' => [[50, 1, 100], [50, 0, 1000], [50, 9, 99999]],
|
130
|
+
'columns' =>
|
131
|
+
[
|
132
|
+
{ 'name' => 'time', 'type' => 'long' },
|
133
|
+
{ 'name' => 'time.min', 'type' => 'long' },
|
134
|
+
{ 'name' => 'time.max', 'type' => 'long' },
|
135
|
+
]
|
136
|
+
} }
|
137
|
+
|
138
|
+
before do
|
139
|
+
allow(esql_executor).to receive(:retryable).and_yield
|
140
|
+
allow(client).to receive_message_chain(:esql, :query).and_return(response)
|
141
|
+
allow(response).to receive(:headers).and_return({})
|
142
|
+
end
|
143
|
+
|
144
|
+
it "includes 1st depth elements into event" do
|
145
|
+
esql_executor.do_run(output_queue, plugin_config["query"])
|
146
|
+
|
147
|
+
expect(output_queue.size).to eq(3)
|
148
|
+
3.times do
|
149
|
+
event = output_queue.pop
|
150
|
+
expect(event.get('time')).to eq(50)
|
151
|
+
expect(event.get('[time][min]')).to eq(nil)
|
152
|
+
expect(event.get('[time][max]')).to eq(nil)
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
describe "#column spec" do
|
159
|
+
let(:valid_spec) { { 'name' => 'field.name', 'type' => 'keyword' } }
|
160
|
+
let(:column_spec) { LogStash::Inputs::Elasticsearch::ColumnSpec.new(valid_spec) }
|
161
|
+
|
162
|
+
context "when initializes" do
|
163
|
+
it "sets the name and type attributes" do
|
164
|
+
expect(column_spec.name).to eq("field.name")
|
165
|
+
expect(column_spec.type).to eq("keyword")
|
166
|
+
end
|
167
|
+
|
168
|
+
it "freezes the name and type attributes" do
|
169
|
+
expect(column_spec.name).to be_frozen
|
170
|
+
expect(column_spec.type).to be_frozen
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
context "when calls the field reference" do
|
175
|
+
it "returns the correct field reference format" do
|
176
|
+
expect(column_spec.field_reference).to eq("[field][name]")
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end if LOGSTASH_VERSION >= LogStash::Inputs::Elasticsearch::LS_ESQL_SUPPORT_VERSION
|
@@ -1357,4 +1357,129 @@ describe LogStash::Inputs::Elasticsearch, :ecs_compatibility_support do
|
|
1357
1357
|
client.transport.respond_to?(:transport) ? client.transport.transport : client.transport
|
1358
1358
|
end
|
1359
1359
|
|
1360
|
+
describe "#ESQL" do
|
1361
|
+
let(:config) do
|
1362
|
+
{
|
1363
|
+
"query" => "FROM test-index | STATS count() BY field",
|
1364
|
+
"query_type" => "esql",
|
1365
|
+
"retries" => 3
|
1366
|
+
}
|
1367
|
+
end
|
1368
|
+
let(:es_version) { LogStash::Inputs::Elasticsearch::ES_ESQL_SUPPORT_VERSION }
|
1369
|
+
let(:ls_version) { LogStash::Inputs::Elasticsearch::LS_ESQL_SUPPORT_VERSION }
|
1370
|
+
|
1371
|
+
before(:each) do
|
1372
|
+
stub_const("LOGSTASH_VERSION", ls_version)
|
1373
|
+
end
|
1374
|
+
|
1375
|
+
describe "#initialize" do
|
1376
|
+
it "sets up the ESQL client with correct parameters" do
|
1377
|
+
expect(plugin.instance_variable_get(:@query_type)).to eq(config["query_type"])
|
1378
|
+
expect(plugin.instance_variable_get(:@query)).to eq(config["query"])
|
1379
|
+
expect(plugin.instance_variable_get(:@retries)).to eq(config["retries"])
|
1380
|
+
end
|
1381
|
+
end
|
1382
|
+
|
1383
|
+
describe "#register" do
|
1384
|
+
before(:each) do
|
1385
|
+
Elasticsearch::Client.send(:define_method, :ping) { }
|
1386
|
+
allow_any_instance_of(Elasticsearch::Client).to receive(:info).and_return(cluster_info)
|
1387
|
+
end
|
1388
|
+
it "creates ES|QL executor" do
|
1389
|
+
plugin.register
|
1390
|
+
expect(plugin.instance_variable_get(:@query_executor)).to be_an_instance_of(LogStash::Inputs::Elasticsearch::Esql)
|
1391
|
+
end
|
1392
|
+
end
|
1393
|
+
|
1394
|
+
describe "#validation" do
|
1395
|
+
|
1396
|
+
describe "LS version" do
|
1397
|
+
context "when compatible" do
|
1398
|
+
|
1399
|
+
it "does not raise an error" do
|
1400
|
+
expect { plugin.send(:validate_ls_version_for_esql_support!) }.not_to raise_error
|
1401
|
+
end
|
1402
|
+
end
|
1403
|
+
|
1404
|
+
context "when incompatible" do
|
1405
|
+
before(:each) do
|
1406
|
+
stub_const("LOGSTASH_VERSION", "8.10.0")
|
1407
|
+
end
|
1408
|
+
|
1409
|
+
it "raises a runtime error" do
|
1410
|
+
expect { plugin.send(:validate_ls_version_for_esql_support!) }
|
1411
|
+
.to raise_error(RuntimeError, /Current version of Logstash does not include Elasticsearch client which supports ES|QL. Please upgrade Logstash to at least #{ls_version}/)
|
1412
|
+
end
|
1413
|
+
end
|
1414
|
+
end
|
1415
|
+
|
1416
|
+
describe "ES version" do
|
1417
|
+
before(:each) do
|
1418
|
+
allow(plugin).to receive(:es_version).and_return("8.10.5")
|
1419
|
+
end
|
1420
|
+
|
1421
|
+
context "when incompatible" do
|
1422
|
+
it "raises a runtime error" do
|
1423
|
+
expect { plugin.send(:validate_es_for_esql_support!) }
|
1424
|
+
.to raise_error(RuntimeError, /Connected Elasticsearch 8.10.5 version does not supports ES|QL. ES|QL feature requires at least Elasticsearch #{es_version} version./)
|
1425
|
+
end
|
1426
|
+
end
|
1427
|
+
end
|
1428
|
+
|
1429
|
+
context "ES|QL query and DSL params used together" do
|
1430
|
+
let(:config) {
|
1431
|
+
super().merge({
|
1432
|
+
"index" => "my-index",
|
1433
|
+
"size" => 1,
|
1434
|
+
"slices" => 1,
|
1435
|
+
"search_api" => "auto",
|
1436
|
+
"docinfo" => true,
|
1437
|
+
"docinfo_target" => "[@metadata][docinfo]",
|
1438
|
+
"docinfo_fields" => ["_index"],
|
1439
|
+
"response_type" => "hits",
|
1440
|
+
"tracking_field" => "[@metadata][tracking]"
|
1441
|
+
})}
|
1442
|
+
|
1443
|
+
it "raises a config error" do
|
1444
|
+
mixed_fields = %w[index size slices docinfo_fields response_type tracking_field]
|
1445
|
+
expect { plugin.register }.to raise_error(LogStash::ConfigurationError, /Configured #{mixed_fields} params are not allowed while using ES|QL query/)
|
1446
|
+
end
|
1447
|
+
end
|
1448
|
+
|
1449
|
+
describe "ES|QL query" do
|
1450
|
+
context "when query is valid" do
|
1451
|
+
it "does not raise an error" do
|
1452
|
+
expect { plugin.send(:validate_esql_query!) }.not_to raise_error
|
1453
|
+
end
|
1454
|
+
end
|
1455
|
+
|
1456
|
+
context "when query is empty" do
|
1457
|
+
let(:config) do
|
1458
|
+
{
|
1459
|
+
"query" => " "
|
1460
|
+
}
|
1461
|
+
end
|
1462
|
+
|
1463
|
+
it "raises a configuration error" do
|
1464
|
+
expect { plugin.send(:validate_esql_query!) }
|
1465
|
+
.to raise_error(LogStash::ConfigurationError, /`query` cannot be empty/)
|
1466
|
+
end
|
1467
|
+
end
|
1468
|
+
|
1469
|
+
context "when query doesn't align with ES syntax" do
|
1470
|
+
let(:config) do
|
1471
|
+
{
|
1472
|
+
"query" => "RANDOM query"
|
1473
|
+
}
|
1474
|
+
end
|
1475
|
+
|
1476
|
+
it "raises a configuration error" do
|
1477
|
+
source_commands = %w[FROM ROW SHOW]
|
1478
|
+
expect { plugin.send(:validate_esql_query!) }
|
1479
|
+
.to raise_error(LogStash::ConfigurationError, "`query` needs to start with any of #{source_commands}")
|
1480
|
+
end
|
1481
|
+
end
|
1482
|
+
end
|
1483
|
+
end
|
1484
|
+
end
|
1360
1485
|
end
|
@@ -0,0 +1,150 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require "logstash/devutils/rspec/spec_helper"
|
3
|
+
require "logstash/inputs/elasticsearch"
|
4
|
+
require "elasticsearch"
|
5
|
+
require_relative "../../../spec/es_helper"
|
6
|
+
|
7
|
+
describe LogStash::Inputs::Elasticsearch, integration: true do
|
8
|
+
|
9
|
+
SECURE_INTEGRATION = ENV['SECURE_INTEGRATION'].eql? 'true'
|
10
|
+
ES_HOSTS = ["http#{SECURE_INTEGRATION ? 's' : nil}://#{ESHelper.get_host_port}"]
|
11
|
+
|
12
|
+
let(:plugin) { described_class.new(config) }
|
13
|
+
let(:es_index) { "logstash-esql-integration-#{rand(1000)}" }
|
14
|
+
let(:test_documents) do
|
15
|
+
[
|
16
|
+
{ "message" => "test message 1", "type" => "a", "count" => 1 },
|
17
|
+
{ "message" => "test message 2", "type" => "a", "count" => 2 },
|
18
|
+
{ "message" => "test message 3", "type" => "b", "count" => 3 },
|
19
|
+
{ "message" => "test message 4", "type" => "b", "count" => 4 },
|
20
|
+
{ "message" => "test message 5", "type" => "c", "count" => 5 }
|
21
|
+
]
|
22
|
+
end
|
23
|
+
let(:config) do
|
24
|
+
{
|
25
|
+
"hosts" => ES_HOSTS,
|
26
|
+
"query_type" => "esql"
|
27
|
+
}
|
28
|
+
end
|
29
|
+
let(:es_client) do
|
30
|
+
Elasticsearch::Client.new(hosts: ES_HOSTS)
|
31
|
+
end
|
32
|
+
|
33
|
+
before(:all) do
|
34
|
+
is_ls_with_esql_supported_client = Gem::Version.create(LOGSTASH_VERSION) >= Gem::Version.create(LogStash::Inputs::Elasticsearch::LS_ESQL_SUPPORT_VERSION)
|
35
|
+
skip "LS version does not have ES client which supports ES|QL" unless is_ls_with_esql_supported_client
|
36
|
+
|
37
|
+
# Skip tests if ES version doesn't support ES||QL
|
38
|
+
es_client = Elasticsearch::Client.new(hosts: ES_HOSTS) # need to separately create since let isn't allowed in before(:context)
|
39
|
+
es_version_info = es_client.info["version"]
|
40
|
+
es_gem_version = Gem::Version.create(es_version_info["number"])
|
41
|
+
skip "ES version does not support ES|QL" if es_gem_version.nil? || es_gem_version < Gem::Version.create(LogStash::Inputs::Elasticsearch::ES_ESQL_SUPPORT_VERSION)
|
42
|
+
end
|
43
|
+
|
44
|
+
before(:each) do
|
45
|
+
# Create index with test documents
|
46
|
+
es_client.indices.create(index: es_index, body: {}) unless es_client.indices.exists?(index: es_index)
|
47
|
+
|
48
|
+
test_documents.each do |doc|
|
49
|
+
es_client.index(index: es_index, body: doc, refresh: true)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
after(:each) do
|
54
|
+
es_client.indices.delete(index: es_index) if es_client.indices.exists?(index: es_index)
|
55
|
+
end
|
56
|
+
|
57
|
+
context "#run ES|QL queries" do
|
58
|
+
|
59
|
+
before do
|
60
|
+
stub_const("LOGSTASH_VERSION", LogStash::Inputs::Elasticsearch::LS_ESQL_SUPPORT_VERSION)
|
61
|
+
allow_any_instance_of(LogStash::Inputs::Elasticsearch).to receive(:exit_plugin?).and_return false, true
|
62
|
+
end
|
63
|
+
|
64
|
+
before(:each) do
|
65
|
+
plugin.register
|
66
|
+
end
|
67
|
+
|
68
|
+
shared_examples "ESQL query execution" do |expected_count|
|
69
|
+
it "correctly retrieves documents" do
|
70
|
+
queue = Queue.new
|
71
|
+
plugin.run(queue)
|
72
|
+
|
73
|
+
event_count = 0
|
74
|
+
expected_count.times do |i|
|
75
|
+
event = queue.pop
|
76
|
+
expect(event).to be_a(LogStash::Event)
|
77
|
+
event_count += 1
|
78
|
+
end
|
79
|
+
expect(event_count).to eq(expected_count)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
context "#FROM query" do
|
84
|
+
let(:config) do
|
85
|
+
super().merge("query" => "FROM #{es_index} | SORT count")
|
86
|
+
end
|
87
|
+
|
88
|
+
include_examples "ESQL query execution", 5
|
89
|
+
end
|
90
|
+
|
91
|
+
context "#FROM query and WHERE clause" do
|
92
|
+
let(:config) do
|
93
|
+
super().merge("query" => "FROM #{es_index} | WHERE type == \"a\" | SORT count")
|
94
|
+
end
|
95
|
+
|
96
|
+
include_examples "ESQL query execution", 2
|
97
|
+
end
|
98
|
+
|
99
|
+
context "#STATS aggregation" do
|
100
|
+
let(:config) do
|
101
|
+
super().merge("query" => "FROM #{es_index} | STATS avg(count) BY type")
|
102
|
+
end
|
103
|
+
|
104
|
+
it "retrieves aggregated stats" do
|
105
|
+
queue = Queue.new
|
106
|
+
plugin.run(queue)
|
107
|
+
results = []
|
108
|
+
3.times do
|
109
|
+
event = queue.pop
|
110
|
+
expect(event).to be_a(LogStash::Event)
|
111
|
+
results << event.get("avg(count)")
|
112
|
+
end
|
113
|
+
|
114
|
+
expected_averages = [1.5, 3.5, 5.0]
|
115
|
+
expect(results.sort).to eq(expected_averages)
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
context "#METADATA" do
|
120
|
+
let(:config) do
|
121
|
+
super().merge("query" => "FROM #{es_index} METADATA _index, _id, _version | DROP message.keyword, type.keyword | SORT count")
|
122
|
+
end
|
123
|
+
|
124
|
+
it "includes document metadata" do
|
125
|
+
queue = Queue.new
|
126
|
+
plugin.run(queue)
|
127
|
+
|
128
|
+
5.times do
|
129
|
+
event = queue.pop
|
130
|
+
expect(event).to be_a(LogStash::Event)
|
131
|
+
expect(event.get("_index")).not_to be_nil
|
132
|
+
expect(event.get("_id")).not_to be_nil
|
133
|
+
expect(event.get("_version")).not_to be_nil
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
context "#invalid ES|QL query" do
|
139
|
+
let(:config) do
|
140
|
+
super().merge("query" => "FROM undefined index | LIMIT 1")
|
141
|
+
end
|
142
|
+
|
143
|
+
it "doesn't produce events" do
|
144
|
+
queue = Queue.new
|
145
|
+
plugin.run(queue)
|
146
|
+
expect(queue.empty?).to eq(true)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: logstash-input-elasticsearch
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 4.
|
4
|
+
version: 4.23.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Elastic
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-06-06 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
@@ -279,6 +279,7 @@ files:
|
|
279
279
|
- lib/logstash/inputs/elasticsearch.rb
|
280
280
|
- lib/logstash/inputs/elasticsearch/aggregation.rb
|
281
281
|
- lib/logstash/inputs/elasticsearch/cursor_tracker.rb
|
282
|
+
- lib/logstash/inputs/elasticsearch/esql.rb
|
282
283
|
- lib/logstash/inputs/elasticsearch/paginated_search.rb
|
283
284
|
- lib/logstash/inputs/elasticsearch/patches/_elasticsearch_transport_connections_selector.rb
|
284
285
|
- lib/logstash/inputs/elasticsearch/patches/_elasticsearch_transport_http_manticore.rb
|
@@ -293,8 +294,10 @@ files:
|
|
293
294
|
- spec/fixtures/test_certs/es.key
|
294
295
|
- spec/fixtures/test_certs/renew.sh
|
295
296
|
- spec/inputs/cursor_tracker_spec.rb
|
297
|
+
- spec/inputs/elasticsearch_esql_spec.rb
|
296
298
|
- spec/inputs/elasticsearch_spec.rb
|
297
299
|
- spec/inputs/elasticsearch_ssl_spec.rb
|
300
|
+
- spec/inputs/integration/elasticsearch_esql_spec.rb
|
298
301
|
- spec/inputs/integration/elasticsearch_spec.rb
|
299
302
|
- spec/inputs/paginated_search_spec.rb
|
300
303
|
homepage: https://elastic.co/logstash
|
@@ -333,7 +336,9 @@ test_files:
|
|
333
336
|
- spec/fixtures/test_certs/es.key
|
334
337
|
- spec/fixtures/test_certs/renew.sh
|
335
338
|
- spec/inputs/cursor_tracker_spec.rb
|
339
|
+
- spec/inputs/elasticsearch_esql_spec.rb
|
336
340
|
- spec/inputs/elasticsearch_spec.rb
|
337
341
|
- spec/inputs/elasticsearch_ssl_spec.rb
|
342
|
+
- spec/inputs/integration/elasticsearch_esql_spec.rb
|
338
343
|
- spec/inputs/integration/elasticsearch_spec.rb
|
339
344
|
- spec/inputs/paginated_search_spec.rb
|