elastic-transport 8.0.0 → 8.4.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/license.yml +2 -2
  3. data/.github/workflows/otel.yml +48 -0
  4. data/.github/workflows/tests.yml +45 -5
  5. data/.gitignore +1 -1
  6. data/CHANGELOG.md +131 -8
  7. data/CONTRIBUTING.md +64 -0
  8. data/Gemfile +10 -9
  9. data/Gemfile-faraday1.gemfile +40 -0
  10. data/README.md +7 -528
  11. data/Rakefile +48 -1
  12. data/elastic-transport.gemspec +6 -9
  13. data/lib/elastic/transport/client.rb +66 -45
  14. data/lib/elastic/transport/meta_header.rb +21 -12
  15. data/lib/elastic/transport/opentelemetry.rb +166 -0
  16. data/lib/elastic/transport/transport/base.rb +74 -54
  17. data/lib/elastic/transport/transport/errors.rb +2 -4
  18. data/lib/elastic/transport/transport/http/curb.rb +30 -26
  19. data/lib/elastic/transport/transport/http/faraday.rb +30 -27
  20. data/lib/elastic/transport/transport/http/manticore.rb +10 -4
  21. data/lib/elastic/transport/transport/response.rb +3 -3
  22. data/lib/elastic/transport/transport/serializer/multi_json.rb +3 -3
  23. data/lib/elastic/transport/transport/sniffer.rb +3 -1
  24. data/lib/elastic/transport/version.rb +1 -1
  25. data/lib/elastic/transport.rb +1 -0
  26. data/spec/elastic/transport/base_spec.rb +26 -25
  27. data/spec/elastic/transport/client_spec.rb +91 -18
  28. data/spec/elastic/transport/http/manticore_spec.rb +20 -2
  29. data/spec/elastic/transport/meta_header_spec.rb +26 -11
  30. data/spec/elastic/transport/opentelemetry_spec.rb +325 -0
  31. data/spec/elastic/transport/sniffer_spec.rb +18 -0
  32. data/spec/spec_helper.rb +16 -1
  33. data/test/integration/jruby_test.rb +1 -1
  34. data/test/integration/transport_test.rb +86 -40
  35. data/test/test_helper.rb +9 -6
  36. data/test/unit/adapters_test.rb +104 -0
  37. data/test/unit/connection_test.rb +35 -37
  38. data/test/unit/transport_base_test.rb +7 -8
  39. data/test/unit/transport_curb_test.rb +2 -3
  40. data/test/unit/transport_manticore_test.rb +1 -1
  41. metadata +23 -76
@@ -15,7 +15,6 @@
15
15
  # specific language governing permissions and limitations
16
16
  # under the License.
17
17
 
18
- require 'base64'
19
18
  require 'elastic/transport/meta_header'
20
19
 
21
20
  module Elastic
@@ -84,7 +83,7 @@ module Elastic
84
83
  #
85
84
  # @option arguments [Integer] :sniffer_timeout Timeout for reloading connections in seconds (1 by default)
86
85
  #
87
- # @option arguments [Boolean,Number] :retry_on_failure Retry X times when request fails before raising and
86
+ # @option arguments [Boolean,Number] :retry_on_failure Retry X times when request fails before raising an
88
87
  # exception (false by default)
89
88
  # @option arguments [Number] :delay_on_retry Delay in milliseconds between each retry (0 by default)
90
89
  #
@@ -92,7 +91,8 @@ module Elastic
92
91
  #
93
92
  # @option arguments [Boolean] :reload_on_failure Reload connections after failure (false by default)
94
93
  #
95
- # @option arguments [Integer] :request_timeout The request timeout to be passed to transport in options
94
+ # @option arguments [Integer] :request_timeout The request timeout to be passed to transport in options in seconds
95
+ # (the default value is taken from the transport)
96
96
  #
97
97
  # @option arguments [Symbol] :adapter A specific adapter for Faraday (e.g. `:patron`)
98
98
  #
@@ -111,7 +111,7 @@ module Elastic
111
111
  #
112
112
  # @option arguments [String] :send_get_body_as Specify the HTTP method to use for GET requests with a body.
113
113
  # (Default: GET)
114
- # @option arguments [true, false] :compression Whether to compress requests. Gzip compression will be used.
114
+ # @option arguments [Boolean] :compression Whether to compress requests. Gzip compression will be used.
115
115
  # The default is false. Responses will automatically be inflated if they are compressed.
116
116
  # If a custom transport object is used, it must handle the request compression and response inflation.
117
117
  #
@@ -122,10 +122,9 @@ module Elastic
122
122
  # @yield [faraday] Access and configure the `Faraday::Connection` instance directly with a block
123
123
  #
124
124
  def initialize(arguments = {}, &block)
125
- @options = arguments.transform_keys(&:to_sym)
126
- @arguments = @options
127
- @arguments[:logger] ||= @arguments[:log] ? DEFAULT_LOGGER.call() : nil
128
- @arguments[:tracer] ||= @arguments[:trace] ? DEFAULT_TRACER.call() : nil
125
+ @arguments = arguments.transform_keys(&:to_sym)
126
+ @arguments[:logger] ||= @arguments[:log] ? DEFAULT_LOGGER.call : nil
127
+ @arguments[:tracer] ||= @arguments[:trace] ? DEFAULT_TRACER.call : nil
129
128
  @arguments[:reload_connections] ||= false
130
129
  @arguments[:retry_on_failure] ||= false
131
130
  @arguments[:delay_on_retry] ||= 0
@@ -134,15 +133,8 @@ module Elastic
134
133
  @arguments[:transport_options] ||= {}
135
134
  @arguments[:http] ||= {}
136
135
  @arguments[:enable_meta_header] = arguments.fetch(:enable_meta_header, true)
137
- @options[:http] ||= {}
138
-
139
- @hosts ||= __extract_hosts(@arguments[:hosts] ||
140
- @arguments[:host] ||
141
- @arguments[:url] ||
142
- @arguments[:urls] ||
143
- ENV['ELASTICSEARCH_URL'] ||
144
- DEFAULT_HOST)
145
136
 
137
+ @hosts ||= extract_hosts
146
138
  @send_get_body_as = @arguments[:send_get_body_as] || 'GET'
147
139
  @ca_fingerprint = @arguments.delete(:ca_fingerprint)
148
140
 
@@ -155,7 +147,7 @@ module Elastic
155
147
  else
156
148
  @transport_class = @arguments[:transport_class] || DEFAULT_TRANSPORT_CLASS
157
149
  @transport = if @transport_class == Transport::HTTP::Faraday
158
- @arguments[:adapter] ||= __auto_detect_adapter
150
+ @arguments[:adapter] ||= auto_detect_adapter
159
151
  set_meta_header # from include MetaHeader
160
152
  @transport_class.new(hosts: @hosts, options: @arguments) do |faraday|
161
153
  faraday.adapter(@arguments[:adapter])
@@ -166,14 +158,39 @@ module Elastic
166
158
  @transport_class.new(hosts: @hosts, options: @arguments)
167
159
  end
168
160
  end
161
+
162
+ if defined?(::OpenTelemetry) && ENV[OpenTelemetry::ENV_VARIABLE_ENABLED] != 'false'
163
+ @otel = OpenTelemetry.new(@arguments)
164
+ end
169
165
  end
170
166
 
171
167
  # Performs a request through delegation to {#transport}.
172
168
  #
173
- def perform_request(method, path, params = {}, body = nil, headers = nil)
174
- method = @send_get_body_as if 'GET' == method && body
169
+ def perform_request(method, path, params = {}, body = nil, headers = nil, opts = {})
170
+ method = @send_get_body_as if method == 'GET' && body
175
171
  validate_ca_fingerprints if @ca_fingerprint
176
- transport.perform_request(method, path, params, body, headers)
172
+ if @otel
173
+ # If no endpoint is specified in the opts, use the HTTP method name
174
+ span_name = opts[:endpoint] || method
175
+ @otel.tracer.in_span(span_name) do |span|
176
+ span['http.request.method'] = method
177
+ span['db.system'] = 'elasticsearch'
178
+ opts[:defined_params]&.each do |k, v|
179
+ if v.respond_to?(:join)
180
+ span["db.elasticsearch.path_parts.#{k}"] = v.join(',')
181
+ else
182
+ span["db.elasticsearch.path_parts.#{k}"] = v
183
+ end
184
+ end
185
+ if body_as_json = @otel.process_body(body, opts[:endpoint])
186
+ span['db.statement'] = body_as_json
187
+ end
188
+ span['db.operation'] = opts[:endpoint] if opts[:endpoint]
189
+ transport.perform_request(method, path, params || {}, body, headers)
190
+ end
191
+ else
192
+ transport.perform_request(method, path, params || {}, body, headers)
193
+ end
177
194
  end
178
195
 
179
196
  private
@@ -211,14 +228,6 @@ module Elastic
211
228
  )
212
229
  end
213
230
 
214
- def add_header(header)
215
- headers = @arguments[:transport_options]&.[](:headers) || {}
216
- headers.merge!(header)
217
- @arguments[:transport_options].merge!(
218
- headers: headers
219
- )
220
- end
221
-
222
231
  # Normalizes and returns hosts configuration.
223
232
  #
224
233
  # Arrayifies the `hosts_config` argument and extracts `host` and `port` info from strings.
@@ -231,7 +240,7 @@ module Elastic
231
240
  #
232
241
  # @api private
233
242
  #
234
- def __extract_hosts(hosts_config)
243
+ def extract_hosts
235
244
  hosts = case hosts_config
236
245
  when String
237
246
  hosts_config.split(',').map { |h| h.strip! || h }
@@ -242,12 +251,20 @@ module Elastic
242
251
  else
243
252
  Array(hosts_config)
244
253
  end
254
+ host_list = hosts.map { |host| parse_host(host) }
255
+ @arguments[:randomize_hosts] ? host_list.shuffle! : host_list
256
+ end
245
257
 
246
- host_list = hosts.map { |host| __parse_host(host) }
247
- @options[:randomize_hosts] ? host_list.shuffle! : host_list
258
+ def hosts_config
259
+ @arguments[:hosts] ||
260
+ @arguments[:host] ||
261
+ @arguments[:url] ||
262
+ @arguments[:urls] ||
263
+ ENV['ELASTICSEARCH_URL'] ||
264
+ DEFAULT_HOST
248
265
  end
249
266
 
250
- def __parse_host(host)
267
+ def parse_host(host)
251
268
  host_parts = case host
252
269
  when String
253
270
  if host =~ /^[a-z]+\:\/\//
@@ -281,8 +298,8 @@ module Elastic
281
298
  raise ArgumentError, "Please pass host as a String, URI or Hash -- #{host.class} given."
282
299
  end
283
300
 
284
- @options[:http][:user] ||= host_parts[:user]
285
- @options[:http][:password] ||= host_parts[:password]
301
+ @arguments[:http][:user] ||= host_parts[:user]
302
+ @arguments[:http][:password] ||= host_parts[:password]
286
303
  host_parts[:port] = host_parts[:port].to_i if host_parts[:port]
287
304
  host_parts[:path].chomp!('/') if host_parts[:path]
288
305
  host_parts
@@ -291,24 +308,28 @@ module Elastic
291
308
  # Auto-detect the best adapter (HTTP "driver") available, based on libraries
292
309
  # loaded by the user, preferring those with persistent connections
293
310
  # ("keep-alive") by default
311
+ # Check adapters based on the usage of Faraday 1 or 2. Faraday should be defined here
312
+ # since this is only called when transport class is Transport::HTTP::Faraday
294
313
  #
295
314
  # @return [Symbol]
296
315
  #
297
316
  # @api private
298
317
  #
299
- def __auto_detect_adapter
300
- case
301
- when defined?(::Patron)
302
- :patron
303
- when defined?(::Typhoeus)
304
- :typhoeus
305
- when defined?(::HTTPClient)
306
- :httpclient
307
- when defined?(::Net::HTTP::Persistent)
308
- :net_http_persistent
318
+ def auto_detect_adapter
319
+ if Gem::Version.new(Faraday::VERSION) >= Gem::Version.new(2)
320
+ return :patron if defined?(Faraday::Adapter::Patron)
321
+ return :typhoeus if defined?(Faraday::Adapter::Typhoeus)
322
+ return :httpclient if defined?(Faraday::Adapter::HTTPClient)
323
+ return :net_http_persistent if defined?(Faraday::Adapter::NetHttpPersistent)
324
+ return :excon if defined?(Faraday::Adapter::Excon)
325
+ return :async_http if defined?(Async::HTTP::Faraday)
309
326
  else
310
- ::Faraday.default_adapter
327
+ return :patron if defined?(::Patron)
328
+ return :typhoeus if defined?(::Typhoeus)
329
+ return :httpclient if defined?(::HTTPClient)
330
+ return :net_http_persistent if defined?(::Net::HTTP::Persistent)
311
331
  end
332
+ ::Faraday.default_adapter
312
333
  end
313
334
  end
314
335
  end
@@ -15,8 +15,6 @@
15
15
  # specific language governing permissions and limitations
16
16
  # under the License.
17
17
 
18
- require 'base64'
19
-
20
18
  module Elastic
21
19
  module Transport
22
20
  # Methods for the Elastic meta header used by Cloud.
@@ -43,12 +41,18 @@ module Elastic
43
41
  def meta_header_service_version
44
42
  if enterprise_search?
45
43
  Elastic::ENTERPRISE_SERVICE_VERSION
44
+ elsif serverless?
45
+ if defined?(Elastic::ES_SERVERLESS_SERVICE_VERSION)
46
+ Elastic::ES_SERVERLESS_SERVICE_VERSION
47
+ else
48
+ Elastic::ELASTICSEARCH_SERVICE_VERSION
49
+ end
46
50
  elsif elasticsearch?
47
51
  Elastic::ELASTICSEARCH_SERVICE_VERSION
48
52
  elsif defined?(Elasticsearch::VERSION)
49
53
  [:es, client_meta_version(Elasticsearch::VERSION)]
50
54
  else
51
- [:es, client_meta_version(Elastic::Transport::VERSION)]
55
+ [:et, client_meta_version(Elastic::Transport::VERSION)]
52
56
  end
53
57
  end
54
58
 
@@ -62,6 +66,11 @@ module Elastic
62
66
  called_from?('elasticsearch')
63
67
  end
64
68
 
69
+ def serverless?
70
+ defined?(ElasticsearchServerless::CLIENT_VERSION) &&
71
+ called_from?('elasticsearch-serverless')
72
+ end
73
+
65
74
  def called_from?(service)
66
75
  !caller.select { |c| c.match?(service) }.empty?
67
76
  end
@@ -72,7 +81,7 @@ module Elastic
72
81
  def client_meta_version(version)
73
82
  regexp = /^([0-9]+\.[0-9]+\.[0-9]+)(\.?[a-z0-9.-]+)?$/
74
83
  match = version.match(regexp)
75
- return "#{match[1]}p" if (match[2])
84
+ return "#{match[1]}p" if match[2]
76
85
 
77
86
  version
78
87
  end
@@ -103,31 +112,31 @@ module Elastic
103
112
  adapter_version = case @arguments[:adapter]
104
113
  when :patron
105
114
  version = Patron::VERSION if defined?(::Patron::VERSION)
106
- {pt: version}
115
+ { pt: version }
107
116
  when :net_http
108
117
  version = if defined?(Net::HTTP::VERSION)
109
118
  Net::HTTP::VERSION
110
119
  elsif defined?(Net::HTTP::HTTPVersion)
111
120
  Net::HTTP::HTTPVersion
112
121
  end
113
- {nh: version}
122
+ { nh: version }
114
123
  when :typhoeus
115
124
  version = Typhoeus::VERSION if defined?(::Typhoeus::VERSION)
116
- {ty: version}
125
+ { ty: version }
117
126
  when :httpclient
118
127
  version = HTTPClient::VERSION if defined?(HTTPClient::VERSION)
119
- {hc: version}
128
+ { hc: version }
120
129
  when :net_http_persistent
121
130
  version = Net::HTTP::Persistent::VERSION if defined?(Net::HTTP::Persistent::VERSION)
122
- {np: version}
131
+ { np: version }
123
132
  else
124
133
  {}
125
134
  end
126
- {fd: Faraday::VERSION}.merge(adapter_version)
135
+ { fd: Faraday::VERSION }.merge(adapter_version)
127
136
  elsif defined?(Transport::HTTP::Curb) && @transport_class == Transport::HTTP::Curb
128
- {cl: Curl::CURB_VERSION}
137
+ { cl: Curl::CURB_VERSION }
129
138
  elsif defined?(Transport::HTTP::Manticore) && @transport_class == Transport::HTTP::Manticore
130
- {mc: Manticore::VERSION}
139
+ { mc: Manticore::VERSION }
131
140
  end
132
141
  end
133
142
  end
@@ -0,0 +1,166 @@
1
+ # Licensed to Elasticsearch B.V. under one or more contributor
2
+ # license agreements. See the NOTICE file distributed with
3
+ # this work for additional information regarding copyright
4
+ # ownership. Elasticsearch B.V. licenses this file to you under
5
+ # the Apache License, Version 2.0 (the "License"); you may
6
+ # not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing,
12
+ # software distributed under the License is distributed on an
13
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ # KIND, either express or implied. See the License for the
15
+ # specific language governing permissions and limitations
16
+ # under the License.
17
+
18
+ module Elastic
19
+ module Transport
20
+ # Wrapper object for Open Telemetry objects, associated config and functionality.
21
+ #
22
+ # @api private
23
+ class OpenTelemetry
24
+ OTEL_TRACER_NAME = 'elasticsearch-api'.freeze
25
+ # Valid values for the enabled config are 'true' and 'false'. Default is 'true'.
26
+ ENV_VARIABLE_ENABLED = 'OTEL_RUBY_INSTRUMENTATION_ELASTICSEARCH_ENABLED'.freeze
27
+ # Describes how to handle search queries in the request body when assigned to
28
+ # a span attribute.
29
+ # Valid values are 'raw', 'omit', 'sanitize'. Default is 'omit'.
30
+ ENV_VARIABLE_BODY_STRATEGY = 'OTEL_RUBY_INSTRUMENTATION_ELASTICSEARCH_CAPTURE_SEARCH_QUERY'.freeze
31
+ ENV_VARIABLE_DEPRECATED_BODY_STRATEGY = 'OTEL_INSTRUMENTATION_ELASTICSEARCH_CAPTURE_SEARCH_QUERY'.freeze
32
+ DEFAULT_BODY_STRATEGY = 'omit'.freeze
33
+ # A string list of keys whose values are redacted. This is only relevant if the body strategy is
34
+ # 'sanitize'. For example, a config 'sensitive-key,other-key' will redact the values at
35
+ # 'sensitive-key' and 'other-key' in addition to the default keys.
36
+ ENV_VARIABLE_BODY_SANITIZE_KEYS = 'OTEL_RUBY_INSTRUMENTATION_ELASTICSEARCH_SEARCH_QUERY_SANITIZE_KEYS'.freeze
37
+
38
+ # A list of the Elasticsearch endpoints that qualify as "search" endpoints. The search query in
39
+ # the request body may be captured for these endpoints, depending on the body capture strategy.
40
+ SEARCH_ENDPOINTS = Set[
41
+ 'search',
42
+ 'async_search.submit',
43
+ 'msearch',
44
+ 'eql.search',
45
+ 'terms_enum',
46
+ 'search_template',
47
+ 'msearch_template',
48
+ 'render_search_template',
49
+ ]
50
+
51
+ # Initialize the Open Telemetry wrapper object. Takes the options originally passed to
52
+ # Client#initialize.
53
+ def initialize(opts)
54
+ @tracer = tracer_provider(opts).tracer(OTEL_TRACER_NAME, Elastic::Transport::VERSION)
55
+ @body_strategy = ENV[ENV_VARIABLE_DEPRECATED_BODY_STRATEGY] ||
56
+ ENV[ENV_VARIABLE_BODY_STRATEGY] ||
57
+ DEFAULT_BODY_STRATEGY
58
+ @sanitize_keys = ENV[ENV_VARIABLE_BODY_SANITIZE_KEYS]&.split(',')&.collect! do |pattern|
59
+ Regexp.new(pattern.gsub('*', '.*'))
60
+ end
61
+ end
62
+ attr_accessor :tracer
63
+
64
+ def tracer_provider(opts)
65
+ opts[:opentelemetry_tracer_provider] || ::OpenTelemetry.tracer_provider
66
+ end
67
+
68
+ # Process the request body. Applies the body strategy, which can be one of the following:
69
+ # 'omit' (DEFAULT_BODY_STRATEGY): return nil
70
+ # 'sanitize': redact values at the default list of keys + any additional keys provided in
71
+ # the OTEL_RUBY_INSTRUMENTATION_ELASTICSEARCH_SEARCH_QUERY_SANITIZE_KEYS env variable.
72
+ # 'raw': return the original body, unchanged
73
+ def process_body(body, endpoint)
74
+ return if @body_strategy == DEFAULT_BODY_STRATEGY || !search_endpoint?(endpoint)
75
+
76
+ if @body_strategy == 'sanitize'
77
+ Sanitizer.sanitize(body, @sanitize_keys).to_json
78
+ elsif @body_strategy == 'raw'
79
+ body.is_a?(String) ? body : body.to_json
80
+ end
81
+ end
82
+
83
+ def search_endpoint?(endpoint)
84
+ SEARCH_ENDPOINTS.include?(endpoint)
85
+ end
86
+
87
+ # Replaces values in a hash with 'REDACTED', given a set of keys to match on.
88
+ class Sanitizer
89
+ class << self
90
+ FILTERED = 'REDACTED'.freeze
91
+ DEFAULT_KEY_PATTERNS =
92
+ %w[password passwd pwd secret *key *token* *session* *credit* *card* *auth* set-cookie].map! do |p|
93
+ Regexp.new(p.gsub('*', '.*'))
94
+ end
95
+
96
+ def sanitize(body, key_patterns = [])
97
+ patterns = DEFAULT_KEY_PATTERNS
98
+ patterns += key_patterns if key_patterns
99
+ sanitize!(DeepDup.dup(body), patterns)
100
+ end
101
+
102
+ private
103
+
104
+ def sanitize!(obj, key_patterns)
105
+ return obj unless obj.is_a?(Hash)
106
+
107
+ obj.each_pair do |k, v|
108
+ if filter_key?(key_patterns, k)
109
+ obj[k] = FILTERED
110
+ elsif v.is_a?(Hash)
111
+ sanitize!(v, key_patterns)
112
+ else
113
+ next
114
+ end
115
+ end
116
+ end
117
+
118
+ def filter_key?(key_patterns, key)
119
+ key_patterns.any? { |regex| regex.match(key) }
120
+ end
121
+ end
122
+ end
123
+
124
+ # Makes a deep copy of an Array or Hash
125
+ # NB: Not guaranteed to work well with complex objects, only simple Hash,
126
+ # Array, String, Number, etc.
127
+ class DeepDup
128
+ def initialize(obj)
129
+ @obj = obj
130
+ end
131
+
132
+ def dup
133
+ deep_dup(@obj)
134
+ end
135
+
136
+ def self.dup(obj)
137
+ new(obj).dup
138
+ end
139
+
140
+ private
141
+
142
+ def deep_dup(obj)
143
+ case obj
144
+ when Hash then hash(obj)
145
+ when Array then array(obj)
146
+ else obj.dup
147
+ end
148
+ end
149
+
150
+ def array(arr)
151
+ arr.map { |obj| deep_dup(obj) }
152
+ end
153
+
154
+ def hash(hsh)
155
+ result = hsh.dup
156
+
157
+ hsh.each_pair do |key, value|
158
+ result[key] = deep_dup(value)
159
+ end
160
+
161
+ result
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end