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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4b69fd432de3b9ad93091c9e1ada1469965f039f302ec8e4fa68c51f30954445
4
- data.tar.gz: 20f9f41949e985e1eca21d8370e704260c780c88659536b496d9c405adcf4838
3
+ metadata.gz: 4659e2475716f4e770b355e8fc7d05aac897efe0f392b78845e1d2d72278b82c
4
+ data.tar.gz: dc57e5eb3592d4106a04a68be952e72863a0d9315f0458880871c54d2827531a
5
5
  SHA512:
6
- metadata.gz: 0b22f9d333bd434006a11e3d8ca15a082bc57365bda305897776b625c9d19b1059533f3b1af97b958e11966bb2f3951f35c928345b0fd03a41e299588088061e
7
- data.tar.gz: 4b3cac772e4ea9d4586923dee83e1e626efffacbfbda02348dc2e09cf704c04011a1cfec5146ce490997c05bdaa62f9206ab2c2de3d3224e8dbe7d0f649c35b2
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
- Format is `id:api_key` where `id` and `api_key` are as returned by the
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. Read the {ref}/query-dsl.html[Elasticsearch query DSL
499
- documentation] for more information.
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
- When set to `aggregations`, a single Logstash event will be generated with the
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
- # The query to be executed. Read the Elasticsearch query DSL documentation
100
- # for more info
101
- # https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html
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 speccify the response type: either hits or aggregations
105
- # where hits: normal search request
106
- # aggregations: aggregation request
107
- config :response_type, :validate => ['hits', 'aggregations'], :default => 'hits'
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
- # format is id:api_key (as returned by https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-api-key.html[Create API key])
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
- @base_query = LogStash::Json.load(@query)
313
- if @slices
314
- @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")
315
- @slices < 1 && fail(LogStash::ConfigurationError, "Elasticsearch Input Plugin's `slices` option must be greater than zero, got `#{@slices}`")
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
- setup_query_executor
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 && api_key.value)
603
+ return {} unless (api_key&.value)
579
604
 
580
- token = ::Base64.strict_encode64(api_key.value)
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 setup_query_executor
704
- @query_executor = case @response_type
705
- when 'hits'
706
- if @resolved_search_api == "search_after"
707
- LogStash::Inputs::Elasticsearch::SearchAfter.new(@client, self)
708
- else
709
- logger.warn("scroll API is no longer recommended for pagination. Consider using search_after instead.") if es_major_version >= 8
710
- LogStash::Inputs::Elasticsearch::Scroll.new(@client, self)
711
- end
712
- when 'aggregations'
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.22.0'
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
- 2024-12-26T22:27:15+00:00
1
+ 2026-01-23T17:38:25+01:00
@@ -1,7 +1,7 @@
1
1
  -----BEGIN CERTIFICATE-----
2
2
  MIIDFTCCAf2gAwIBAgIBATANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylFbGFz
3
- dGljIENlcnRpZmljYXRlIFRvb2wgQXV0b2dlbmVyYXRlZCBDQTAeFw0yNDEyMjYy
4
- MjI3MTVaFw0yNTEyMjYyMjI3MTVaMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlm
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
- AQsFAAOCAQEAhfg/cmXc4Uh90yiXU8jOW8saQjTsq4ZMDQiLfJsNmNNYmHFN0vhv
14
- lJRI1STdy7+GpjS5QbrMjQIxWSS8X8xysE4Rt81IrWmLuao35TRFyoiE1seBQ5sz
15
- p/BxZUe57JvWi9dyzv2df4UfWFdGBhzdr80odZmz4i5VIv6qCKJKsGikcuLpepmp
16
- E/UKnKHeR/dFWsxzA9P2OzHTUNBMOOA2PyAUL49pwoChwJeOWN/zAgwMWLbuHFG0
17
- IN0u8swAmeH98QdvzbhiOatGNpqfTNvQEDc19yVjfXKpBVZQ79WtronYSqrbrUa1
18
- T2zD8bIVP7CdddD/UmpT1SSKh4PJxudy5Q==
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
- b1e955819b0d14f64f863adb103c248ddacf2e17bea48d04ee4b57c64814ccc4
1
+ 28c468dcfc5ed4626eeb748a91093c272dda52804c62e2ee1d8fd31c182009ae