mongo 2.22.0 → 2.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/mongo/client.rb +40 -4
  3. data/lib/mongo/cluster.rb +4 -1
  4. data/lib/mongo/collection/view/aggregation/behavior.rb +1 -1
  5. data/lib/mongo/collection/view/aggregation.rb +5 -2
  6. data/lib/mongo/collection/view/iterable.rb +16 -14
  7. data/lib/mongo/collection/view/readable.rb +64 -55
  8. data/lib/mongo/collection/view/writable.rb +52 -46
  9. data/lib/mongo/collection/view.rb +2 -0
  10. data/lib/mongo/collection.rb +56 -46
  11. data/lib/mongo/config.rb +4 -0
  12. data/lib/mongo/crypt/auto_decryption_context.rb +9 -0
  13. data/lib/mongo/crypt/binding.rb +1 -1
  14. data/lib/mongo/crypt/context.rb +10 -0
  15. data/lib/mongo/crypt/explicit_decryption_context.rb +9 -0
  16. data/lib/mongo/database/view.rb +25 -20
  17. data/lib/mongo/database.rb +17 -10
  18. data/lib/mongo/deprecations.rb +98 -0
  19. data/lib/mongo/index/view.rb +28 -19
  20. data/lib/mongo/operation/create.rb +4 -0
  21. data/lib/mongo/operation/insert/op_msg.rb +5 -2
  22. data/lib/mongo/operation/shared/executable.rb +11 -4
  23. data/lib/mongo/operation/shared/specifiable.rb +5 -1
  24. data/lib/mongo/search_index/view.rb +29 -9
  25. data/lib/mongo/server/app_metadata/platform.rb +17 -4
  26. data/lib/mongo/server/connection.rb +18 -0
  27. data/lib/mongo/server/description/features.rb +37 -8
  28. data/lib/mongo/server.rb +2 -1
  29. data/lib/mongo/session.rb +55 -19
  30. data/lib/mongo/srv/monitor.rb +5 -1
  31. data/lib/mongo/srv/result.rb +14 -4
  32. data/lib/mongo/tracing/open_telemetry/command_tracer.rb +320 -0
  33. data/lib/mongo/tracing/open_telemetry/operation_tracer.rb +227 -0
  34. data/lib/mongo/tracing/open_telemetry/tracer.rb +236 -0
  35. data/lib/mongo/tracing/open_telemetry.rb +27 -0
  36. data/lib/mongo/tracing.rb +42 -0
  37. data/lib/mongo/uri/srv_protocol.rb +11 -6
  38. data/lib/mongo/version.rb +1 -1
  39. data/lib/mongo.rb +3 -0
  40. metadata +8 -2
data/lib/mongo/server.rb CHANGED
@@ -218,7 +218,8 @@ module Mongo
218
218
  # @api private
219
219
  def_delegators :cluster,
220
220
  :monitor_app_metadata,
221
- :push_monitor_app_metadata
221
+ :push_monitor_app_metadata,
222
+ :tracer
222
223
 
223
224
  def_delegators :features,
224
225
  :check_driver_support!
data/lib/mongo/session.rb CHANGED
@@ -130,6 +130,8 @@ module Mongo
130
130
  # @since 2.5.0
131
131
  attr_reader :operation_time
132
132
 
133
+ def_delegators :client, :tracer
134
+
133
135
  # Sets the dirty state to the given value for the underlying server
134
136
  # session. If there is no server session, this does nothing.
135
137
  #
@@ -446,20 +448,14 @@ module Mongo
446
448
  #
447
449
  # @since 2.7.0
448
450
  def with_transaction(options = nil)
449
- if timeout_ms = (options || {})[:timeout_ms]
450
- timeout_sec = timeout_ms / 1_000.0
451
- deadline = Utils.monotonic_time + timeout_sec
452
- @with_transaction_deadline = deadline
453
- elsif default_timeout_ms = @options[:default_timeout_ms]
454
- timeout_sec = default_timeout_ms / 1_000.0
455
- deadline = Utils.monotonic_time + timeout_sec
456
- @with_transaction_deadline = deadline
457
- elsif @client.timeout_sec
458
- deadline = Utils.monotonic_time + @client.timeout_sec
459
- @with_transaction_deadline = deadline
460
- else
461
- deadline = Utils.monotonic_time + 120
462
- end
451
+ @with_transaction_deadline = calculate_with_transaction_deadline(options)
452
+ deadline = if @with_transaction_deadline
453
+ # CSOT enabled, so we have a customer defined deadline.
454
+ @with_transaction_deadline
455
+ else
456
+ # CSOT not enabled, so we use the default deadline, 120 seconds.
457
+ Utils.monotonic_time + 120
458
+ end
463
459
  transaction_in_progress = false
464
460
  loop do
465
461
  commit_options = {}
@@ -478,7 +474,7 @@ module Mongo
478
474
  transaction_in_progress = false
479
475
  end
480
476
 
481
- if Utils.monotonic_time >= deadline
477
+ if deadline_expired?(deadline)
482
478
  transaction_in_progress = false
483
479
  raise
484
480
  end
@@ -500,7 +496,7 @@ module Mongo
500
496
  return rv
501
497
  rescue Mongo::Error => e
502
498
  if e.label?('UnknownTransactionCommitResult')
503
- if Utils.monotonic_time >= deadline ||
499
+ if deadline_expired?(deadline) ||
504
500
  e.is_a?(Error::OperationFailure::Family) && e.max_time_ms_expired?
505
501
  then
506
502
  transaction_in_progress = false
@@ -622,6 +618,7 @@ module Mongo
622
618
 
623
619
  @state = STARTING_TRANSACTION_STATE
624
620
  @already_committed = false
621
+ tracer.start_transaction_span(self)
625
622
 
626
623
  # This method has no explicit return value.
627
624
  # We could return nil here but true indicates to the user that the
@@ -701,9 +698,14 @@ module Mongo
701
698
  txn_num: txn_num,
702
699
  write_concern: write_concern,
703
700
  }
704
- Operation::Command.new(spec).execute_with_connection(connection, context: context)
701
+ operation = Operation::Command.new(spec)
702
+ tracer.trace_operation(operation, context, op_name: 'commitTransaction') do
703
+ operation.execute_with_connection(connection, context: context)
704
+ end
705
705
  end
706
706
  end
707
+ # Finish the transaction span before changing state
708
+ tracer.finish_transaction_span(self)
707
709
  ensure
708
710
  @state = TRANSACTION_COMMITTED_STATE
709
711
  @committing_transaction = false
@@ -758,24 +760,31 @@ module Mongo
758
760
  ending_transaction: true, context: context,
759
761
  ) do |connection, txn_num, context|
760
762
  begin
761
- Operation::Command.new(
763
+ operation = Operation::Command.new(
762
764
  selector: { abortTransaction: 1 },
763
765
  db_name: 'admin',
764
766
  session: self,
765
767
  txn_num: txn_num
766
- ).execute_with_connection(connection, context: context)
768
+ )
769
+ tracer.trace_operation(operation, context, op_name: 'abortTransaction') do
770
+ operation.execute_with_connection(connection, context: context)
771
+ end
767
772
  ensure
768
773
  unpin
769
774
  end
770
775
  end
771
776
  end
772
777
 
778
+ # Finish the transaction span before changing state
779
+ tracer.finish_transaction_span(self)
773
780
  @state = TRANSACTION_ABORTED_STATE
774
781
  rescue Mongo::Error::InvalidTransactionOperation
775
782
  raise
776
783
  rescue Mongo::Error
784
+ tracer.finish_transaction_span(self)
777
785
  @state = TRANSACTION_ABORTED_STATE
778
786
  rescue Exception
787
+ tracer.finish_transaction_span(self)
779
788
  @state = TRANSACTION_ABORTED_STATE
780
789
  raise
781
790
  ensure
@@ -1191,6 +1200,8 @@ module Mongo
1191
1200
  # @api private
1192
1201
  attr_accessor :snapshot_timestamp
1193
1202
 
1203
+ # @return [ Integer | nil ] The deadline for the current transaction, if any.
1204
+ # @api private
1194
1205
  attr_reader :with_transaction_deadline
1195
1206
 
1196
1207
  private
@@ -1286,5 +1297,30 @@ module Mongo
1286
1297
  end
1287
1298
  end
1288
1299
  end
1300
+
1301
+ def calculate_with_transaction_deadline(opts)
1302
+ calc = -> (timeout) {
1303
+ if timeout == 0
1304
+ 0
1305
+ else
1306
+ Utils.monotonic_time + (timeout / 1000.0)
1307
+ end
1308
+ }
1309
+ if timeout_ms = opts&.dig(:timeout_ms)
1310
+ calc.call(timeout_ms)
1311
+ elsif default_timeout_ms = @options[:default_timeout_ms]
1312
+ calc.call(default_timeout_ms)
1313
+ elsif @client.timeout_ms
1314
+ calc.call(@client.timeout_ms)
1315
+ end
1316
+ end
1317
+
1318
+ def deadline_expired?(deadline)
1319
+ if deadline.zero?
1320
+ false
1321
+ else
1322
+ Utils.monotonic_time >= deadline
1323
+ end
1324
+ end
1289
1325
  end
1290
1326
  end
@@ -72,7 +72,11 @@ module Mongo
72
72
  def scan!
73
73
  begin
74
74
  last_result = Timeout.timeout(timeout) do
75
- @resolver.get_records(@srv_uri.query_hostname)
75
+ @resolver.get_records(
76
+ @srv_uri.query_hostname,
77
+ @srv_uri.uri_options[:srv_service_name] || options[:srv_service_name],
78
+ @srv_uri.uri_options[:srv_max_hosts] || @options[:srv_max_hosts]
79
+ )
76
80
  end
77
81
  rescue Resolv::ResolvTimeout => e
78
82
  log_warn("SRV monitor: timed out trying to resolve hostname #{@srv_uri.query_hostname}: #{e.class}: #{e}")
@@ -110,17 +110,27 @@ module Mongo
110
110
  # A hostname's domain name consists of each of the '.' delineated
111
111
  # parts after the first. For example, the hostname 'foo.bar.baz'
112
112
  # has the domain name 'bar.baz'.
113
+ #
114
+ # If the hostname has less than three parts, its domain name is the hostname itself.
113
115
  #
114
116
  # @param [ String ] record_host The host of the SRV record.
115
117
  #
116
118
  # @raise [ Mongo::Error::MismatchedDomain ] If the record's domain name doesn't match that of
117
119
  # the hostname.
118
120
  def validate_same_origin!(record_host)
119
- domain_name ||= query_hostname.split('.')[1..-1]
120
- host_parts = record_host.split('.')
121
+ srv_host_domain = query_hostname.split('.')
122
+ srv_is_less_than_three_parts = srv_host_domain.length < 3
123
+ unless srv_is_less_than_three_parts
124
+ srv_host_domain = srv_host_domain[1..-1]
125
+ end
126
+ record_host_parts = record_host.split('.')
127
+
128
+ if (srv_is_less_than_three_parts && record_host_parts.length <= srv_host_domain.length)
129
+ raise Error::MismatchedDomain.new(MISMATCHED_DOMAINNAME % [record_host, srv_host_domain])
130
+ end
121
131
 
122
- unless (host_parts.size > domain_name.size) && (domain_name == host_parts[-domain_name.length..-1])
123
- raise Error::MismatchedDomain.new(MISMATCHED_DOMAINNAME % [record_host, domain_name])
132
+ unless (record_host_parts.size > srv_host_domain.size) && (srv_host_domain == record_host_parts[-srv_host_domain.size..-1])
133
+ raise Error::MismatchedDomain.new(MISMATCHED_DOMAINNAME % [record_host, srv_host_domain])
124
134
  end
125
135
  end
126
136
  end
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (C) 2025-present MongoDB Inc.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the 'License');
6
+ # you may 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, software
12
+ # distributed under the License is distributed on an 'AS IS' BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module Mongo
18
+ module Tracing
19
+ module OpenTelemetry
20
+ # CommandTracer is responsible for tracing MongoDB server commands using OpenTelemetry.
21
+ #
22
+ # @api private
23
+ class CommandTracer
24
+ # Initializes a new CommandTracer.
25
+ #
26
+ # @param otel_tracer [ OpenTelemetry::Trace::Tracer ] the OpenTelemetry tracer.
27
+ # @param parent_tracer [ Mongo::Tracing::OpenTelemetry::Tracer ] the parent tracer
28
+ # for accessing shared context maps.
29
+ # @param query_text_max_length [ Integer ] maximum length for captured query text.
30
+ # Defaults to 0 (no query text capture).
31
+ def initialize(otel_tracer, parent_tracer, query_text_max_length: 0)
32
+ @otel_tracer = otel_tracer
33
+ @parent_tracer = parent_tracer
34
+ @query_text_max_length = query_text_max_length
35
+ end
36
+
37
+ # Starts a span for a MongoDB command.
38
+ #
39
+ # @param message [ Mongo::Protocol::Message ] the command message.
40
+ # @param operation_context [ Mongo::Operation::Context ] the operation context.
41
+ # @param connection [ Mongo::Server::Connection ] the connection.
42
+ def start_span(message, operation_context, connection); end
43
+
44
+ # Trace a MongoDB command.
45
+ #
46
+ # Creates an OpenTelemetry span for the command, capturing attributes such as
47
+ # command name, database name, collection name, server address, connection IDs,
48
+ # and optionally query text. The span is automatically nested under the current
49
+ # operation span and is finished when the command completes or fails.
50
+ #
51
+ # @param message [ Mongo::Protocol::Message ] the command message to trace.
52
+ # @param _operation_context [ Mongo::Operation::Context ] the context of the operation.
53
+ # @param connection [ Mongo::Server::Connection ] the connection used to send the command.
54
+ #
55
+ # @yield the block representing the command to be traced.
56
+ #
57
+ # @return [ Object ] the result of the command.
58
+ # rubocop:disable Lint/RescueException
59
+ def trace_command(message, _operation_context, connection)
60
+ # Commands should always be nested under their operation span, not directly under
61
+ # the transaction span. Don't pass with_parent to use automatic parent resolution
62
+ # from the currently active span (the operation span).
63
+ span = create_command_span(message, connection)
64
+ ::OpenTelemetry::Trace.with_span(span) do |s, c|
65
+ yield.tap do |result|
66
+ process_command_result(result, cursor_id(message), c, s)
67
+ end
68
+ end
69
+ rescue Exception => e
70
+ handle_command_exception(span, e)
71
+ raise e
72
+ ensure
73
+ span&.finish
74
+ end
75
+ # rubocop:enable Lint/RescueException
76
+
77
+ private
78
+
79
+ # Creates a span for a command.
80
+ #
81
+ # @param message [ Mongo::Protocol::Message ] the command message.
82
+ # @param connection [ Mongo::Server::Connection ] the connection.
83
+ #
84
+ # @return [ OpenTelemetry::Trace::Span ] the created span.
85
+ def create_command_span(message, connection)
86
+ @otel_tracer.start_span(
87
+ command_name(message),
88
+ attributes: span_attributes(message, connection),
89
+ kind: :client
90
+ )
91
+ end
92
+
93
+ # Processes the command result and updates span attributes.
94
+ #
95
+ # @param result [ Object ] the command result.
96
+ # @param cursor_id [ Integer | nil ] the cursor ID.
97
+ # @param context [ OpenTelemetry::Context ] the context.
98
+ # @param span [ OpenTelemetry::Trace::Span ] the current span.
99
+ def process_command_result(result, cursor_id, context, span)
100
+ process_cursor_context(result, cursor_id, context, span)
101
+ maybe_trace_error(result, span)
102
+ end
103
+
104
+ # Handles exceptions that occur during command execution.
105
+ #
106
+ # @param span [ OpenTelemetry::Trace::Span | nil ] the span.
107
+ # @param exception [ Exception ] the exception that occurred.
108
+ def handle_command_exception(span, exception)
109
+ return unless span
110
+
111
+ if exception.is_a?(Mongo::Error::OperationFailure)
112
+ span.set_attribute('db.response.status_code', exception.code.to_s)
113
+ end
114
+ span.record_exception(exception)
115
+ span.status = ::OpenTelemetry::Trace::Status.error("Unhandled exception of type: #{exception.class}")
116
+ end
117
+
118
+ # Builds span attributes for the command.
119
+ #
120
+ # @param message [ Mongo::Protocol::Message ] the command message.
121
+ # @param connection [ Mongo::Server::Connection ] the connection.
122
+ #
123
+ # @return [ Hash ] OpenTelemetry span attributes following MongoDB semantic conventions.
124
+ def span_attributes(message, connection)
125
+ base_attributes(message)
126
+ .merge(connection_attributes(connection))
127
+ .merge(session_attributes(message))
128
+ .compact
129
+ end
130
+
131
+ # Returns base database and command attributes.
132
+ #
133
+ # @param message [ Mongo::Protocol::Message ] the command message.
134
+ #
135
+ # @return [ Hash ] base span attributes.
136
+ def base_attributes(message)
137
+ {
138
+ 'db.system' => 'mongodb',
139
+ 'db.namespace' => database(message),
140
+ 'db.collection.name' => collection_name(message),
141
+ 'db.command.name' => command_name(message),
142
+ 'db.query.summary' => query_summary(message),
143
+ 'db.query.text' => query_text(message)
144
+ }
145
+ end
146
+
147
+ # Returns connection-related attributes.
148
+ #
149
+ # @param connection [ Mongo::Server::Connection ] the connection.
150
+ #
151
+ # @return [ Hash ] connection span attributes.
152
+ def connection_attributes(connection)
153
+ {
154
+ 'server.port' => connection.address.port,
155
+ 'server.address' => connection.address.host,
156
+ 'network.transport' => connection.transport.to_s,
157
+ 'db.mongodb.server_connection_id' => connection.server.description.server_connection_id,
158
+ 'db.mongodb.driver_connection_id' => connection.id
159
+ }
160
+ end
161
+
162
+ # Returns session and transaction attributes.
163
+ #
164
+ # @param message [ Mongo::Protocol::Message ] the command message.
165
+ #
166
+ # @return [ Hash ] session span attributes.
167
+ def session_attributes(message)
168
+ {
169
+ 'db.mongodb.cursor_id' => cursor_id(message),
170
+ 'db.mongodb.lsid' => lsid(message),
171
+ 'db.mongodb.txn_number' => txn_number(message)
172
+ }
173
+ end
174
+
175
+ # Processes cursor context from the command result.
176
+ #
177
+ # @param result [ Object ] the command result.
178
+ # @param _cursor_id [ Integer | nil ] the cursor ID (unused).
179
+ # @param _context [ OpenTelemetry::Context ] the context (unused).
180
+ # @param span [ OpenTelemetry::Trace::Span ] the current span.
181
+ def process_cursor_context(result, _cursor_id, _context, span)
182
+ return unless result.has_cursor_id? && result.cursor_id.positive?
183
+
184
+ span.set_attribute('db.mongodb.cursor_id', result.cursor_id)
185
+ end
186
+
187
+ # Records error status code if the command failed.
188
+ #
189
+ # @param result [ Object ] the command result.
190
+ # @param span [ OpenTelemetry::Trace::Span ] the current span.
191
+ def maybe_trace_error(result, span)
192
+ return if result.successful?
193
+
194
+ span.set_attribute('db.response.status_code', result.error.code.to_s)
195
+ begin
196
+ result.validate!
197
+ rescue Mongo::Error::OperationFailure => e
198
+ span.record_exception(e)
199
+ end
200
+ end
201
+
202
+ # Generates a summary string for the query.
203
+ #
204
+ # @param message [ Mongo::Protocol::Message ] the command message.
205
+ #
206
+ # @return [ String ] summary in format "command_name db.collection" or "command_name db".
207
+ def query_summary(message)
208
+ if (coll_name = collection_name(message))
209
+ "#{command_name(message)} #{database(message)}.#{coll_name}"
210
+ else
211
+ "#{command_name(message)} #{database(message)}"
212
+ end
213
+ end
214
+
215
+ # Extracts the collection name from the command message.
216
+ #
217
+ # @param message [ Mongo::Protocol::Message ] the command message.
218
+ #
219
+ # @return [ String | nil ] the collection name, or nil if not applicable.
220
+ def collection_name(message)
221
+ case command_name(message)
222
+ when 'getMore'
223
+ message.documents.first['collection'].to_s
224
+ when 'listCollections', 'listDatabases', 'commitTransaction', 'abortTransaction'
225
+ nil
226
+ else
227
+ value = message.documents.first.values.first
228
+ # Return nil if the value is not a string (e.g., for admin commands that have numeric values)
229
+ value.is_a?(String) ? value : nil
230
+ end
231
+ end
232
+
233
+ # Extracts the command name from the message.
234
+ #
235
+ # @param message [ Mongo::Protocol::Message ] the command message.
236
+ #
237
+ # @return [ String ] the command name.
238
+ def command_name(message)
239
+ message.documents.first.keys.first.to_s
240
+ end
241
+
242
+ # Extracts the database name from the message.
243
+ #
244
+ # @param message [ Mongo::Protocol::Message ] the command message.
245
+ #
246
+ # @return [ String ] the database name.
247
+ def database(message)
248
+ message.documents.first['$db'].to_s
249
+ end
250
+
251
+ # Checks if query text capture is enabled.
252
+ #
253
+ # @return [ Boolean ] true if query text should be captured.
254
+ def query_text?
255
+ @query_text_max_length.positive?
256
+ end
257
+
258
+ # Extracts the cursor ID from getMore commands.
259
+ #
260
+ # @param message [ Mongo::Protocol::Message ] the command message.
261
+ #
262
+ # @return [ Integer | nil ] the cursor ID, or nil if not a getMore command.
263
+ def cursor_id(message)
264
+ return unless command_name(message) == 'getMore'
265
+
266
+ message.documents.first['getMore'].value
267
+ end
268
+
269
+ # Extracts the logical session ID from the command.
270
+ #
271
+ # @param message [ Mongo::Protocol::Message ] the command message.
272
+ #
273
+ # @return [ BSON::Binary | nil ] the session ID, or nil if not present.
274
+ def lsid(message)
275
+ lsid_doc = message.documents.first['lsid']
276
+ return unless lsid_doc
277
+
278
+ lsid_doc['id'].to_uuid
279
+ end
280
+
281
+ # Extracts the transaction number from the command.
282
+ #
283
+ # @param message [ Mongo::Protocol::Message ] the command message.
284
+ #
285
+ # @return [ Integer | nil ] the transaction number, or nil if not present.
286
+ def txn_number(message)
287
+ txn_num = message.documents.first['txnNumber']
288
+ return unless txn_num
289
+
290
+ txn_num.value
291
+ end
292
+
293
+ # Keys to exclude from query text capture.
294
+ EXCLUDED_KEYS = %w[lsid $db $clusterTime signature].freeze
295
+
296
+ # Ellipsis for truncated query text.
297
+ ELLIPSIS = '...'
298
+
299
+ # Extracts and formats the query text from the command.
300
+ #
301
+ # @param message [ Mongo::Protocol::Message ] the command message.
302
+ #
303
+ # @return [ String | nil ] JSON representation of the command, truncated if necessary, or nil if disabled.
304
+ def query_text(message)
305
+ return unless query_text?
306
+
307
+ text = message
308
+ .payload['command']
309
+ .reject { |key, _| EXCLUDED_KEYS.include?(key) }
310
+ .to_json
311
+ if text.length > @query_text_max_length
312
+ "#{text[0...@query_text_max_length]}#{ELLIPSIS}"
313
+ else
314
+ text
315
+ end
316
+ end
317
+ end
318
+ end
319
+ end
320
+ end