logstash-input-elasticsearch 4.22.0 → 4.23.1
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 +6 -0
- data/docs/index.asciidoc +126 -6
- data/lib/logstash/inputs/elasticsearch/esql.rb +153 -0
- data/lib/logstash/inputs/elasticsearch.rb +110 -39
- data/logstash-input-elasticsearch.gemspec +2 -2
- data/spec/fixtures/test_certs/GENERATED_AT +1 -1
- data/spec/fixtures/test_certs/ca.crt +8 -8
- data/spec/fixtures/test_certs/ca.der.sha256 +1 -1
- data/spec/fixtures/test_certs/es.chain.crt +16 -16
- data/spec/fixtures/test_certs/es.crt +8 -8
- data/spec/inputs/elasticsearch_esql_spec.rb +180 -0
- data/spec/inputs/elasticsearch_spec.rb +167 -6
- data/spec/inputs/integration/elasticsearch_esql_spec.rb +150 -0
- data/version +1 -0
- metadata +26 -23
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4659e2475716f4e770b355e8fc7d05aac897efe0f392b78845e1d2d72278b82c
|
|
4
|
+
data.tar.gz: dc57e5eb3592d4106a04a68be952e72863a0d9315f0458880871c54d2827531a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 383efe321bc63ee288e974edc589b2edffcf659a8fab8d82edc8290aaf77c02965e0ddc3ba40154cdfa667a3176741c01f7b929ef41f49424e2a333e09c23bfa
|
|
7
|
+
data.tar.gz: d7a5461caba35b6d20ec3034d94489f1ecbd1f74fb51904b2eb6e077756ee68342e9d2b06b55722b4ad40b1f71dfbe071e2381c183e6a38a29912e0b16a7380f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## 4.23.1
|
|
2
|
+
- Support base64-encoded and Elastic Cloud (`essu_`-prefixed) API keys in the `api_key` option; reject unrecognized formats at startup. [#276](https://github.com/logstash-plugins/logstash-input-elasticsearch/pull/276)
|
|
3
|
+
|
|
4
|
+
## 4.23.0
|
|
5
|
+
- ES|QL support [#235](https://github.com/logstash-plugins/logstash-input-elasticsearch/pull/235)
|
|
6
|
+
|
|
1
7
|
## 4.22.0
|
|
2
8
|
- Add "cursor"-like index tracking [#205](https://github.com/logstash-plugins/logstash-input-elasticsearch/pull/205)
|
|
3
9
|
|
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
|
|
@@ -296,10 +401,12 @@ input plugins.
|
|
|
296
401
|
|
|
297
402
|
Authenticate using Elasticsearch API key. Note that this option also requires enabling the <<plugins-{type}s-{plugin}-ssl_enabled>> option.
|
|
298
403
|
|
|
299
|
-
|
|
404
|
+
The format is `id:api_key`, where `id` and `api_key` are as returned by the
|
|
300
405
|
Elasticsearch
|
|
301
406
|
{ref}/security-api-create-api-key.html[Create
|
|
302
|
-
API key API].
|
|
407
|
+
API key API]. The base64-encoded form of that pair is also accepted, as is an
|
|
408
|
+
https://www.elastic.co/docs/deploy-manage/api-keys/elastic-cloud-api-keys[Elastic Cloud API key]
|
|
409
|
+
(prefixed with `essu_`), which is used as-is.
|
|
303
410
|
|
|
304
411
|
[id="plugins-{type}s-{plugin}-ca_trusted_fingerprint"]
|
|
305
412
|
===== `ca_trusted_fingerprint`
|
|
@@ -495,22 +602,35 @@ environment variables e.g. `proxy => '${LS_PROXY:}'`.
|
|
|
495
602
|
* Value type is <<string,string>>
|
|
496
603
|
* Default value is `'{ "sort": [ "_doc" ] }'`
|
|
497
604
|
|
|
498
|
-
The query to be executed.
|
|
499
|
-
|
|
605
|
+
The query to be executed.
|
|
606
|
+
Accepted query shape is DSL or {esql} (when `query_type => 'esql'`).
|
|
607
|
+
Read the {ref}/query-dsl.html[{es} query DSL documentation] or {ref}/esql.html[{esql} documentation] for more information.
|
|
500
608
|
|
|
501
609
|
When <<plugins-{type}s-{plugin}-search_api>> resolves to `search_after` and the query does not specify `sort`,
|
|
502
610
|
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
611
|
|
|
612
|
+
[id="plugins-{type}s-{plugin}-query_type"]
|
|
613
|
+
===== `query_type`
|
|
614
|
+
|
|
615
|
+
* Value can be `dsl` or `esql`
|
|
616
|
+
* Default value is `dsl`
|
|
617
|
+
|
|
618
|
+
Defines the <<plugins-{type}s-{plugin}-query>> shape.
|
|
619
|
+
When `dsl`, the query shape must be valid {es} JSON-style string.
|
|
620
|
+
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.
|
|
621
|
+
|
|
504
622
|
[id="plugins-{type}s-{plugin}-response_type"]
|
|
505
623
|
===== `response_type`
|
|
506
624
|
|
|
507
|
-
* Value can be any of: `hits`, `aggregations`
|
|
625
|
+
* Value can be any of: `hits`, `aggregations`, `esql`
|
|
508
626
|
* Default value is `hits`
|
|
509
627
|
|
|
510
628
|
Which part of the result to transform into Logstash events when processing the
|
|
511
629
|
response from the query.
|
|
630
|
+
|
|
512
631
|
The default `hits` will generate one event per returned document (i.e. "hit").
|
|
513
|
-
|
|
632
|
+
|
|
633
|
+
When set to `aggregations`, a single {ls} event will be generated with the
|
|
514
634
|
contents of the `aggregations` object of the query's response. In this case the
|
|
515
635
|
`hits` object will be ignored. The parameter `size` will be always be set to
|
|
516
636
|
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
|
|
@@ -208,7 +215,8 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
|
208
215
|
config :cloud_auth, :validate => :password
|
|
209
216
|
|
|
210
217
|
# Authenticate using Elasticsearch API key.
|
|
211
|
-
#
|
|
218
|
+
# Format is either the `id:api_key` pair (as returned by https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-api-key.html[Create API key]),
|
|
219
|
+
# its base64-encoded form, or an https://www.elastic.co/docs/deploy-manage/api-keys/elastic-cloud-api-keys[Elastic Cloud API key] (prefixed with `essu_`) can be used.
|
|
212
220
|
config :api_key, :validate => :password
|
|
213
221
|
|
|
214
222
|
# Set the address of a forward HTTP proxy.
|
|
@@ -293,6 +301,9 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
|
293
301
|
DEFAULT_EAV_HEADER = { "Elastic-Api-Version" => "2023-10-31" }.freeze
|
|
294
302
|
INTERNAL_ORIGIN_HEADER = { 'x-elastic-product-origin' => 'logstash-input-elasticsearch'}.freeze
|
|
295
303
|
|
|
304
|
+
LS_ESQL_SUPPORT_VERSION = "8.17.4" # the version started using elasticsearch-ruby v8
|
|
305
|
+
ES_ESQL_SUPPORT_VERSION = "8.11.0"
|
|
306
|
+
|
|
296
307
|
def initialize(params={})
|
|
297
308
|
super(params)
|
|
298
309
|
|
|
@@ -309,10 +320,17 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
|
309
320
|
fill_hosts_from_cloud_id
|
|
310
321
|
setup_ssl_params!
|
|
311
322
|
|
|
312
|
-
@
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
323
|
+
if @query_type == 'esql'
|
|
324
|
+
validate_ls_version_for_esql_support!
|
|
325
|
+
validate_esql_query!
|
|
326
|
+
not_allowed_options = original_params.keys & %w(index size slices search_api docinfo docinfo_target docinfo_fields response_type tracking_field)
|
|
327
|
+
raise(LogStash::ConfigurationError, "Configured #{not_allowed_options} params are not allowed while using ES|QL query") if not_allowed_options&.size > 1
|
|
328
|
+
else
|
|
329
|
+
@base_query = LogStash::Json.load(@query)
|
|
330
|
+
if @slices
|
|
331
|
+
@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")
|
|
332
|
+
@slices < 1 && fail(LogStash::ConfigurationError, "Elasticsearch Input Plugin's `slices` option must be greater than zero, got `#{@slices}`")
|
|
333
|
+
end
|
|
316
334
|
end
|
|
317
335
|
|
|
318
336
|
@retries < 0 && fail(LogStash::ConfigurationError, "Elasticsearch Input Plugin's `retries` option must be equal or greater than zero, got `#{@retries}`")
|
|
@@ -348,11 +366,13 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
|
348
366
|
|
|
349
367
|
test_connection!
|
|
350
368
|
|
|
369
|
+
validate_es_for_esql_support!
|
|
370
|
+
|
|
351
371
|
setup_serverless
|
|
352
372
|
|
|
353
373
|
setup_search_api
|
|
354
374
|
|
|
355
|
-
|
|
375
|
+
@query_executor = create_query_executor
|
|
356
376
|
|
|
357
377
|
setup_cursor_tracker
|
|
358
378
|
|
|
@@ -370,16 +390,6 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
|
370
390
|
end
|
|
371
391
|
end
|
|
372
392
|
|
|
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
393
|
##
|
|
384
394
|
# This can be called externally from the query_executor
|
|
385
395
|
public
|
|
@@ -390,6 +400,23 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
|
390
400
|
record_last_value(event)
|
|
391
401
|
end
|
|
392
402
|
|
|
403
|
+
def decorate_event(event)
|
|
404
|
+
decorate(event)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
private
|
|
408
|
+
|
|
409
|
+
def get_query_object
|
|
410
|
+
return @query if @query_type == 'esql'
|
|
411
|
+
if @cursor_tracker
|
|
412
|
+
query = @cursor_tracker.inject_cursor(@query)
|
|
413
|
+
@logger.debug("new query is #{query}")
|
|
414
|
+
else
|
|
415
|
+
query = @query
|
|
416
|
+
end
|
|
417
|
+
LogStash::Json.load(query)
|
|
418
|
+
end
|
|
419
|
+
|
|
393
420
|
def record_last_value(event)
|
|
394
421
|
@cursor_tracker.record_last_value(event) if @tracking_field
|
|
395
422
|
end
|
|
@@ -421,8 +448,6 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
|
421
448
|
event.set(@docinfo_target, docinfo_target)
|
|
422
449
|
end
|
|
423
450
|
|
|
424
|
-
private
|
|
425
|
-
|
|
426
451
|
def hosts_default?(hosts)
|
|
427
452
|
hosts.nil? || ( hosts.is_a?(Array) && hosts.empty? )
|
|
428
453
|
end
|
|
@@ -575,12 +600,40 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
|
575
600
|
end
|
|
576
601
|
|
|
577
602
|
def setup_api_key(api_key)
|
|
578
|
-
return {} unless (api_key
|
|
603
|
+
return {} unless (api_key&.value)
|
|
579
604
|
|
|
580
|
-
token =
|
|
605
|
+
token = resolve_api_key(api_key.value)
|
|
581
606
|
{ 'Authorization' => "ApiKey #{token}" }
|
|
582
607
|
end
|
|
583
608
|
|
|
609
|
+
# Resolves the `api_key` value into the credential used in the
|
|
610
|
+
# `Authorization: ApiKey` header. An already base64-encoded key and an Elastic
|
|
611
|
+
# Cloud API key are used as-is; a raw `id:api_key` pair is base64-encoded. An
|
|
612
|
+
# unrecognized value is rejected so a malformed key surfaces at startup rather
|
|
613
|
+
# than as a later authentication failure.
|
|
614
|
+
def resolve_api_key(key_value)
|
|
615
|
+
if base64?(key_value) || cloud_api_key?(key_value)
|
|
616
|
+
key_value
|
|
617
|
+
elsif key_value.match?(/\A[^:]+:[^:]+\z/)
|
|
618
|
+
Base64.strict_encode64(key_value)
|
|
619
|
+
else
|
|
620
|
+
raise LogStash::ConfigurationError, "Invalid api_key format. Expected a base64-encoded key, an 'id:api_key' pair, or a Cloud API key (essu_ prefix)."
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# Elastic Cloud API keys (such as the unified Serverless keys) are opaque
|
|
625
|
+
# tokens prefixed with `essu_` that Elasticsearch accepts verbatim in the
|
|
626
|
+
# `Authorization: ApiKey` header, with no base64 encoding.
|
|
627
|
+
def cloud_api_key?(string)
|
|
628
|
+
string.match?(/\Aessu_.+/)
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def base64?(string)
|
|
632
|
+
string == Base64.strict_encode64(Base64.strict_decode64(string))
|
|
633
|
+
rescue ArgumentError
|
|
634
|
+
false
|
|
635
|
+
end
|
|
636
|
+
|
|
584
637
|
def prepare_user_agent
|
|
585
638
|
os_name = java.lang.System.getProperty('os.name')
|
|
586
639
|
os_version = java.lang.System.getProperty('os.version')
|
|
@@ -700,18 +753,16 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
|
700
753
|
|
|
701
754
|
end
|
|
702
755
|
|
|
703
|
-
def
|
|
704
|
-
@
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
LogStash::Inputs::Elasticsearch::Aggregation.new(@client, self)
|
|
714
|
-
end
|
|
756
|
+
def create_query_executor
|
|
757
|
+
return LogStash::Inputs::Elasticsearch::Esql.new(@client, self) if @query_type == 'esql'
|
|
758
|
+
|
|
759
|
+
# DSL query executor
|
|
760
|
+
return LogStash::Inputs::Elasticsearch::Aggregation.new(@client, self) if @response_type == 'aggregations'
|
|
761
|
+
# response_type is hits, executor can be search_after or scroll type
|
|
762
|
+
return LogStash::Inputs::Elasticsearch::SearchAfter.new(@client, self) if @resolved_search_api == "search_after"
|
|
763
|
+
|
|
764
|
+
logger.warn("scroll API is no longer recommended for pagination. Consider using search_after instead.") if es_major_version >= 8
|
|
765
|
+
LogStash::Inputs::Elasticsearch::Scroll.new(@client, self)
|
|
715
766
|
end
|
|
716
767
|
|
|
717
768
|
def setup_cursor_tracker
|
|
@@ -750,6 +801,26 @@ class LogStash::Inputs::Elasticsearch < LogStash::Inputs::Base
|
|
|
750
801
|
::Elastic::Transport::Transport::HTTP::Manticore
|
|
751
802
|
end
|
|
752
803
|
|
|
804
|
+
def validate_ls_version_for_esql_support!
|
|
805
|
+
if Gem::Version.create(LOGSTASH_VERSION) < Gem::Version.create(LS_ESQL_SUPPORT_VERSION)
|
|
806
|
+
fail("Current version of Logstash does not include Elasticsearch client which supports ES|QL. Please upgrade Logstash to at least #{LS_ESQL_SUPPORT_VERSION}")
|
|
807
|
+
end
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
def validate_esql_query!
|
|
811
|
+
fail(LogStash::ConfigurationError, "`query` cannot be empty") if @query.strip.empty?
|
|
812
|
+
source_commands = %w[FROM ROW SHOW]
|
|
813
|
+
contains_source_command = source_commands.any? { |source_command| @query.strip.start_with?(source_command) }
|
|
814
|
+
fail(LogStash::ConfigurationError, "`query` needs to start with any of #{source_commands}") unless contains_source_command
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
def validate_es_for_esql_support!
|
|
818
|
+
return unless @query_type == 'esql'
|
|
819
|
+
# make sure connected ES supports ES|QL (8.11+)
|
|
820
|
+
es_supports_esql = Gem::Version.create(es_version) >= Gem::Version.create(ES_ESQL_SUPPORT_VERSION)
|
|
821
|
+
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
|
|
822
|
+
end
|
|
823
|
+
|
|
753
824
|
module URIOrEmptyValidator
|
|
754
825
|
##
|
|
755
826
|
# @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
|
+
s.version = ::File.read('version').split("\n").first
|
|
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"
|
|
@@ -11,7 +11,7 @@ Gem::Specification.new do |s|
|
|
|
11
11
|
s.require_paths = ["lib"]
|
|
12
12
|
|
|
13
13
|
# Files
|
|
14
|
-
s.files = Dir["lib/**/*","spec/**/*","*.gemspec","*.md","CONTRIBUTORS","Gemfile","LICENSE","NOTICE.TXT", "vendor/jar-dependencies/**/*.jar", "vendor/jar-dependencies/**/*.rb", "VERSION", "docs/**/*"]
|
|
14
|
+
s.files = Dir["lib/**/*","spec/**/*","*.gemspec","*.md","CONTRIBUTORS","Gemfile","LICENSE","NOTICE.TXT", "vendor/jar-dependencies/**/*.jar", "vendor/jar-dependencies/**/*.rb", "VERSION", "version", "docs/**/*"]
|
|
15
15
|
|
|
16
16
|
# Tests
|
|
17
17
|
s.test_files = s.files.grep(%r{^(test|spec|features)/})
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
2026-01-23T17:38:25+01:00
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
-----BEGIN CERTIFICATE-----
|
|
2
2
|
MIIDFTCCAf2gAwIBAgIBATANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylFbGFz
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
dGljIENlcnRpZmljYXRlIFRvb2wgQXV0b2dlbmVyYXRlZCBDQTAeFw0yNjAxMjMx
|
|
4
|
+
NjM4MjVaFw0yNzAxMjMxNjM4MjVaMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlm
|
|
5
5
|
aWNhdGUgVG9vbCBBdXRvZ2VuZXJhdGVkIENBMIIBIjANBgkqhkiG9w0BAQEFAAOC
|
|
6
6
|
AQ8AMIIBCgKCAQEArUe66xG4Y2zO13gRC+rBwyvxe+c01pqV6ukw6isIbJIQWs1/
|
|
7
7
|
QfEMhUwYwKs6/UXxK+VwardcA2zYwngXbGGEtms+mpUfH5CdJnrqW7lHz1BVK4yH
|
|
@@ -10,10 +10,10 @@ QfEMhUwYwKs6/UXxK+VwardcA2zYwngXbGGEtms+mpUfH5CdJnrqW7lHz1BVK4yH
|
|
|
10
10
|
i4lUiR6Uo9D6WMFjeRYFF7GolCy/I1SzWBmmOnNhQLO5VxcNG4ldhBcapZeGwE98
|
|
11
11
|
m/5lxLIwgFR9ZP8bXdxZTWLC58/LQ2NqOjA9mwIDAQABozIwMDAPBgNVHRMBAf8E
|
|
12
12
|
BTADAQH/MB0GA1UdDgQWBBTIJMnuftpfkxNCOkbF0R4xgcKQRjANBgkqhkiG9w0B
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
AQsFAAOCAQEAgZPz/e29AepHZu8uuO6+75b6Uf88m8x5m7XL13m9+vTTSqLvCiBw
|
|
14
|
+
JCf5caO73iOx8AlTT2iSlKPAS/7ogik5anZziqweHmBakb+HNQcUB4Vv4VBo5Ai3
|
|
15
|
+
SdOm28uu4EznVLUILPUHl4FQGxhn6ba1tLEWgxlD/ynBtfcmh9UFtBqIawrpAArv
|
|
16
|
+
TKwGBSkcSxh/yOIo+qbyBh9EyxxlGW/Z08m9qjNqHATDxCelcTsQLhCHkvNShR4j
|
|
17
|
+
v1sFw5XQ8H3zQU6w4WzV81A3K8erIU8WnvdisXLk0T1VB5KURy4+UDyw44tOAPD2
|
|
18
|
+
vBq3gYLgcVEMYVfOWCzDy7LS49leXRzAOA==
|
|
19
19
|
-----END CERTIFICATE-----
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
28c468dcfc5ed4626eeb748a91093c272dda52804c62e2ee1d8fd31c182009ae
|