diff --git a/gemfiles/standard.rb b/gemfiles/standard.rb index c8065b3a1b..0d534f78d8 100644 --- a/gemfiles/standard.rb +++ b/gemfiles/standard.rb @@ -4,6 +4,7 @@ def standard_dependencies gem 'yard', '>= 0.9.35' gem 'ffi' + gem 'opentelemetry-sdk' group :development, :testing do gem 'jruby-openssl', platforms: :jruby diff --git a/lib/mongo.rb b/lib/mongo.rb index c866ad1a9e..0e62e92415 100644 --- a/lib/mongo.rb +++ b/lib/mongo.rb @@ -33,6 +33,7 @@ autoload :CGI, 'cgi' require 'bson' +require 'opentelemetry-api' require 'mongo/id' require 'mongo/bson' @@ -74,6 +75,7 @@ require 'mongo/socket' require 'mongo/srv' require 'mongo/timeout' +require 'mongo/tracing' require 'mongo/uri' require 'mongo/version' require 'mongo/write_concern' diff --git a/lib/mongo/client.rb b/lib/mongo/client.rb index 70f5768628..e52691b0a8 100644 --- a/lib/mongo/client.rb +++ b/lib/mongo/client.rb @@ -112,6 +112,7 @@ class Client :ssl_verify_hostname, :ssl_verify_ocsp_endpoint, :timeout_ms, + :tracing, :truncate_logs, :user, :wait_queue_timeout, @@ -437,6 +438,20 @@ def hash # See Ruby's Zlib module for valid levels. # @option options [ Hash ] :resolv_options For internal driver use only. # Options to pass through to Resolv::DNS constructor for SRV lookups. + # @option options [ Hash ] :tracing OpenTelemetry tracing options. + # - :enabled => Boolean, whether to enable OpenTelemetry tracing. The default + # value is nil that means that the configuration will be taken from the + # OTEL_RUBY_INSTRUMENTATION_MONGODB_ENABLED environment variable. + # - :tracer => OpenTelemetry::Trace::Tracer, the tracer to use for + # tracing. Must be an implementation of OpenTelemetry::Trace::Tracer + # interface. + # - :query_text_max_length => Integer, the maximum length of the query text + # to be included in the span attributes. If the query text exceeds this + # length, it will be truncated. Value 0 means no query text + # will be included in the span attributes. The default value is nil that + # means that the configuration will be taken from the + # OTEL_RUBY_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH environment + # variable. # @option options [ Hash ] :auto_encryption_options Auto-encryption related # options. # - :key_vault_client => Client | nil, a client connected to the MongoDB @@ -574,8 +589,12 @@ def initialize(addresses_or_uri, options = nil) @connect_lock = Mutex.new @connect_lock.synchronize do - @cluster = Cluster.new(addresses, @monitoring, - cluster_options.merge(srv_uri: srv_uri)) + @cluster = Cluster.new( + addresses, + @monitoring, + tracer, + cluster_options.merge(srv_uri: srv_uri) + ) end begin @@ -893,7 +912,7 @@ def reconnect @connect_lock.synchronize do do_close rescue nil - @cluster = Cluster.new(addresses, monitoring, cluster_options) + @cluster = Cluster.new(addresses, monitoring, tracer, cluster_options) if @options[:auto_encryption_options] build_encrypter @@ -965,7 +984,10 @@ def list_databases(filter = {}, name_only = false, opts = {}) cmd[:nameOnly] = !!name_only cmd[:filter] = filter unless filter.empty? cmd[:authorizedDatabases] = true if opts[:authorized_databases] - use(Database::ADMIN).database.read_command(cmd, opts).first[Database::DATABASES] + use(Database::ADMIN) + .database + .read_command(cmd, opts.merge(op_name: 'listDatabases')) + .first[Database::DATABASES] end # Returns a list of Mongo::Database objects. @@ -1195,6 +1217,15 @@ def timeout_sec end end + def tracer + tracing_opts = @options[:tracing] || {} + @tracer ||= Tracing.create_tracer( + enabled: tracing_opts[:enabled], + query_text_max_length: tracing_opts[:query_text_max_length], + otel_tracer: tracing_opts[:tracer], + ) + end + private # Attempts to parse the given list of addresses, using the provided options. diff --git a/lib/mongo/cluster.rb b/lib/mongo/cluster.rb index 46e9f556f1..30a7e56583 100644 --- a/lib/mongo/cluster.rb +++ b/lib/mongo/cluster.rb @@ -117,7 +117,7 @@ class Cluster # - *:deprecation_errors* -- boolean # # @since 2.0.0 - def initialize(seeds, monitoring, options = Options::Redacted.new) + def initialize(seeds, monitoring, tracer = nil, options = Options::Redacted.new) if seeds.nil? raise ArgumentError, 'Seeds cannot be nil' end @@ -136,6 +136,7 @@ def initialize(seeds, monitoring, options = Options::Redacted.new) @update_lock = Mutex.new @servers = [] @monitoring = monitoring + @tracer = tracer @event_listeners = Event::Listeners.new @app_metadata = Server::AppMetadata.new(@options.merge(purpose: :application)) @monitor_app_metadata = Server::Monitor::AppMetadata.new(@options.merge(purpose: :monitor)) @@ -309,6 +310,8 @@ def self.create(client, monitoring: nil) # @return [ Monitoring ] monitoring The monitoring. attr_reader :monitoring + attr_reader :tracer + # @return [ Object ] The cluster topology. attr_reader :topology diff --git a/lib/mongo/collection.rb b/lib/mongo/collection.rb index b9cbefee0c..12cae2b7a9 100644 --- a/lib/mongo/collection.rb +++ b/lib/mongo/collection.rb @@ -57,6 +57,8 @@ class Collection # Delegate to the cluster for the next primary. def_delegators :cluster, :next_primary + def_delegators :client, :tracer + # Options that can be updated on a new Collection instance via the #with method. # # @since 2.1.0 @@ -410,21 +412,24 @@ def create(opts = {}) client: client, session: session ) - maybe_create_qe_collections(opts[:encrypted_fields], client, session) do |encrypted_fields| - Operation::Create.new( - selector: operation, - db_name: database.name, - write_concern: write_concern, - session: session, - # Note that these are collection options, collation isn't - # taken from options passed to the create method. - collation: options[:collation] || options['collation'], - encrypted_fields: encrypted_fields, - validator: options[:validator], - ).execute( - next_primary(nil, session), - context: context - ) + operation = Operation::Create.new( + selector: operation, + db_name: database.name, + write_concern: write_concern, + session: session, + # Note that these are collection options, collation isn't + # taken from options passed to the create method. + collation: options[:collation] || options['collation'], + validator: options[:validator], + ) + tracer.trace_operation(operation, context, op_name: 'createCollection') do + maybe_create_qe_collections(opts[:encrypted_fields], client, session) do |encrypted_fields| + operation.encrypted_fields = encrypted_fields + operation.execute( + next_primary(nil, session), + context: context + ) + end end end end @@ -453,25 +458,27 @@ def create(opts = {}) # @since 2.0.0 def drop(opts = {}) client.with_session(opts) do |session| - maybe_drop_emm_collections(opts[:encrypted_fields], client, session) do - temp_write_concern = write_concern - write_concern = if opts[:write_concern] - WriteConcern.get(opts[:write_concern]) - else - temp_write_concern + context = Operation::Context.new( + client: client, + session: session, + operation_timeouts: operation_timeouts(opts) + ) + operation = Operation::Drop.new({ + selector: { :drop => name }, + db_name: database.name, + write_concern: write_concern, + session: session, + }) + tracer.trace_operation(operation, context, op_name: 'dropCollection') do + maybe_drop_emm_collections(opts[:encrypted_fields], client, session) do + temp_write_concern = write_concern + write_concern = if opts[:write_concern] + WriteConcern.get(opts[:write_concern]) + else + temp_write_concern + end + do_drop(operation, session, context) end - context = Operation::Context.new( - client: client, - session: session, - operation_timeouts: operation_timeouts(opts) - ) - operation = Operation::Drop.new({ - selector: { :drop => name }, - db_name: database.name, - write_concern: write_concern, - session: session, - }) - do_drop(operation, session, context) end end end @@ -865,19 +872,22 @@ def insert_one(document, opts = {}) session: session, operation_timeouts: operation_timeouts(opts) ) - write_with_retry(write_concern, context: context) do |connection, txn_num, context| - Operation::Insert.new( - :documents => [ document ], - :db_name => database.name, - :coll_name => name, - :write_concern => write_concern, - :bypass_document_validation => !!opts[:bypass_document_validation], - :options => opts, - :id_generator => client.options[:id_generator], - :session => session, - :txn_num => txn_num, - :comment => opts[:comment] - ).execute_with_connection(connection, context: context) + operation = Operation::Insert.new( + :documents => [ document ], + :db_name => database.name, + :coll_name => name, + :write_concern => write_concern, + :bypass_document_validation => !!opts[:bypass_document_validation], + :options => opts, + :id_generator => client.options[:id_generator], + :session => session, + :comment => opts[:comment] + ) + tracer.trace_operation(operation, context) do + write_with_retry(write_concern, context: context) do |connection, txn_num, context| + operation.txn_num = txn_num + operation.execute_with_connection(connection, context: context) + end end end end diff --git a/lib/mongo/collection/view.rb b/lib/mongo/collection/view.rb index fc33d85b75..e7c221f0a8 100644 --- a/lib/mongo/collection/view.rb +++ b/lib/mongo/collection/view.rb @@ -72,6 +72,8 @@ class View # Delegate to the cluster for the next primary. def_delegators :cluster, :next_primary + def_delegators :client, :tracer + alias :selector :filter # @return [ Integer | nil | The timeout_ms value that was passed as an diff --git a/lib/mongo/collection/view/aggregation.rb b/lib/mongo/collection/view/aggregation.rb index f80a4f491b..5c263faeda 100644 --- a/lib/mongo/collection/view/aggregation.rb +++ b/lib/mongo/collection/view/aggregation.rb @@ -25,11 +25,14 @@ class View # # @since 2.0.0 class Aggregation + extend Forwardable include Behavior # @return [ Array ] pipeline The aggregation pipeline. attr_reader :pipeline + def_delegators :view, :tracer + # Initialize the aggregation for the provided collection view, pipeline # and options. # @@ -80,7 +83,7 @@ def new(options) Aggregation.new(view, pipeline, options) end - def initial_query_op(session, read_preference) + def initial_query_op(session, read_preference = nil) Operation::Aggregate.new(aggregate_spec(session, read_preference)) end diff --git a/lib/mongo/collection/view/aggregation/behavior.rb b/lib/mongo/collection/view/aggregation/behavior.rb index 349b82e4bc..db881106a7 100644 --- a/lib/mongo/collection/view/aggregation/behavior.rb +++ b/lib/mongo/collection/view/aggregation/behavior.rb @@ -88,7 +88,7 @@ def server_selector @view.send(:server_selector) end - def aggregate_spec(session, read_preference) + def aggregate_spec(session, read_preference = nil) Builder::Aggregation.new( pipeline, view, diff --git a/lib/mongo/collection/view/iterable.rb b/lib/mongo/collection/view/iterable.rb index 99133c5e9f..0f88e8eec7 100644 --- a/lib/mongo/collection/view/iterable.rb +++ b/lib/mongo/collection/view/iterable.rb @@ -88,19 +88,21 @@ def select_cursor(session) operation_timeouts: operation_timeouts, view: self ) - - if respond_to?(:write?, true) && write? - server = server_selector.select_server(cluster, nil, session, write_aggregation: true) - result = send_initial_query(server, context) - - if use_query_cache? - CachingCursor.new(view, result, server, session: session, context: context) + op = initial_query_op(session) + tracer.trace_operation(op, context) do + if respond_to?(:write?, true) && write? + server = server_selector.select_server(cluster, nil, session, write_aggregation: true) + result = send_initial_query(server, context) + + if use_query_cache? + CachingCursor.new(view, result, server, session: session, context: context) + else + Cursor.new(view, result, server, session: session, context: context) + end else - Cursor.new(view, result, server, session: session, context: context) - end - else - read_with_retry_cursor(session, server_selector, view, context: context) do |server| - send_initial_query(server, context) + read_with_retry_cursor(session, server_selector, view, context: context) do |server| + send_initial_query(server, context) + end end end end diff --git a/lib/mongo/collection/view/readable.rb b/lib/mongo/collection/view/readable.rb index 05fcc78df7..99b14a1a66 100644 --- a/lib/mongo/collection/view/readable.rb +++ b/lib/mongo/collection/view/readable.rb @@ -192,22 +192,25 @@ def count(opts = {}) session: session, operation_timeouts: operation_timeouts(opts) ) - read_with_retry(session, selector, context) do |server| - Operation::Count.new( - selector: cmd, - db_name: database.name, - options: {:limit => -1}, - read: read_pref, - session: session, - # For some reason collation was historically accepted as a - # string key. Note that this isn't documented as valid usage. - collation: opts[:collation] || opts['collation'] || collation, - comment: opts[:comment], - ).execute( - server, - context: context - ) - end.n.to_i + operation = Operation::Count.new( + selector: cmd, + db_name: database.name, + options: {:limit => -1}, + read: read_pref, + session: session, + # For some reason collation was historically accepted as a + # string key. Note that this isn't documented as valid usage. + collation: opts[:collation] || opts['collation'] || collation, + comment: opts[:comment], + ) + tracer.trace_operation(operation, context) do + read_with_retry(session, selector, context) do |server| + operation.execute( + server, + context: context + ) + end.n.to_i + end end end @@ -294,32 +297,35 @@ def estimated_document_count(opts = {}) session: session, operation_timeouts: operation_timeouts(opts) ) - read_with_retry(session, selector, context) do |server| - cmd = { count: collection.name } - cmd[:maxTimeMS] = opts[:max_time_ms] if opts[:max_time_ms] - if read_concern - cmd[:readConcern] = Options::Mapper.transform_values_to_strings(read_concern) + cmd = { count: collection.name } + cmd[:maxTimeMS] = opts[:max_time_ms] if opts[:max_time_ms] + if read_concern + cmd[:readConcern] = Options::Mapper.transform_values_to_strings(read_concern) + end + operation = Operation::Count.new( + selector: cmd, + db_name: database.name, + read: read_pref, + session: session, + comment: opts[:comment], + ) + tracer.trace_operation(operation, context, op_name: 'estimatedDocumentCount') do + read_with_retry(session, selector, context) do |server| + result = operation.execute(server, context: context) + result.n.to_i + end + rescue Error::OperationFailure::Family => exc + if exc.code == 26 + # NamespaceNotFound + # This should only happen with the aggregation pipeline path + # (server 4.9+). Previous servers should return 0 for nonexistent + # collections. + 0 + else + raise end - result = Operation::Count.new( - selector: cmd, - db_name: database.name, - read: read_pref, - session: session, - comment: opts[:comment], - ).execute(server, context: context) - result.n.to_i end end - rescue Error::OperationFailure::Family => exc - if exc.code == 26 - # NamespaceNotFound - # This should only happen with the aggregation pipeline path - # (server 4.9+). Previous servers should return 0 for nonexistent - # collections. - 0 - else - raise - end end # Get a list of distinct values for a specific field. @@ -362,22 +368,25 @@ def distinct(field_name, opts = {}) session: session, operation_timeouts: operation_timeouts(opts) ) - read_with_retry(session, selector, context) do |server| - Operation::Distinct.new( - selector: cmd, - db_name: database.name, - options: {:limit => -1}, - read: read_pref, - session: session, - comment: opts[:comment], - # For some reason collation was historically accepted as a - # string key. Note that this isn't documented as valid usage. - collation: opts[:collation] || opts['collation'] || collation, - ).execute( - server, - context: context - ) - end.first['values'] + operation = Operation::Distinct.new( + selector: cmd, + db_name: database.name, + options: {:limit => -1}, + read: read_pref, + session: session, + comment: opts[:comment], + # For some reason collation was historically accepted as a + # string key. Note that this isn't documented as valid usage. + collation: opts[:collation] || opts['collation'] || collation, + ) + tracer.trace_operation(operation, context) do + read_with_retry(session, selector, context) do |server| + operation.execute( + server, + context: context + ) + end.first['values'] + end end end diff --git a/lib/mongo/collection/view/writable.rb b/lib/mongo/collection/view/writable.rb index 0a1f553b1d..49aaefd397 100644 --- a/lib/mongo/collection/view/writable.rb +++ b/lib/mongo/collection/view/writable.rb @@ -211,22 +211,24 @@ def find_one_and_update(document, opts = {}) session: session, operation_timeouts: operation_timeouts(opts) ) - write_with_retry(write_concern, context: context) do |connection, txn_num, context| - gte_4_4 = connection.server.description.server_version_gte?('4.4') - if !gte_4_4 && opts[:hint] && write_concern && !write_concern.acknowledged? - raise Error::UnsupportedOption.hint_error(unacknowledged_write: true) + operation = Operation::WriteCommand.new( + selector: cmd, + db_name: database.name, + write_concern: write_concern, + session: session, + ) + tracer.trace_operation(operation, context, op_name: 'findOneAndUpdate') do + write_with_retry(write_concern, context: context) do |connection, txn_num, context| + gte_4_4 = connection.server.description.server_version_gte?('4.4') + if !gte_4_4 && opts[:hint] && write_concern && !write_concern.acknowledged? + raise Error::UnsupportedOption.hint_error(unacknowledged_write: true) + end + operation.txn_num = txn_num + operation.execute_with_connection(connection, context: context) end - - Operation::WriteCommand.new( - selector: cmd, - db_name: database.name, - write_concern: write_concern, - session: session, - txn_num: txn_num, - ).execute_with_connection(connection, context: context) - end - end.first&.fetch('value', nil) - value unless value.nil? || value.empty? + end.first&.fetch('value', nil) + value unless value.nil? || value.empty? + end end # Remove documents from the collection. @@ -275,22 +277,24 @@ def delete_many(opts = {}) session: session, operation_timeouts: operation_timeouts(opts) ) - nro_write_with_retry(write_concern, context: context) do |connection, txn_num, context| - gte_4_4 = connection.server.description.server_version_gte?('4.4') - if !gte_4_4 && opts[:hint] && write_concern && !write_concern.acknowledged? - raise Error::UnsupportedOption.hint_error(unacknowledged_write: true) + operation = Operation::Delete.new( + deletes: [ delete_doc ], + db_name: collection.database.name, + coll_name: collection.name, + write_concern: write_concern, + bypass_document_validation: !!opts[:bypass_document_validation], + session: session, + let: opts[:let], + comment: opts[:comment], + ) + tracer.trace_operation(operation, context, op_name: 'deleteMany') do + nro_write_with_retry(write_concern, context: context) do |connection, txn_num, context| + gte_4_4 = connection.server.description.server_version_gte?('4.4') + if !gte_4_4 && opts[:hint] && write_concern && !write_concern.acknowledged? + raise Error::UnsupportedOption.hint_error(unacknowledged_write: true) + end + operation.execute_with_connection(connection, context: context) end - - Operation::Delete.new( - deletes: [ delete_doc ], - db_name: collection.database.name, - coll_name: collection.name, - write_concern: write_concern, - bypass_document_validation: !!opts[:bypass_document_validation], - session: session, - let: opts[:let], - comment: opts[:comment], - ).execute_with_connection(connection, context: context) end end end @@ -580,23 +584,25 @@ def update_one(spec, opts = {}) session: session, operation_timeouts: operation_timeouts(opts) ) - write_with_retry(write_concern, context: context) do |connection, txn_num, context| - gte_4_2 = connection.server.description.server_version_gte?('4.2') - if !gte_4_2 && opts[:hint] && write_concern && !write_concern.acknowledged? - raise Error::UnsupportedOption.hint_error(unacknowledged_write: true) + operation = Operation::Update.new( + updates: [ update_doc ], + db_name: collection.database.name, + coll_name: collection.name, + write_concern: write_concern, + bypass_document_validation: !!opts[:bypass_document_validation], + session: session, + let: opts[:let], + comment: opts[:comment], + ) + tracer.trace_operation(operation, context) do + write_with_retry(write_concern, context: context) do |connection, txn_num, context| + gte_4_2 = connection.server.description.server_version_gte?('4.2') + if !gte_4_2 && opts[:hint] && write_concern && !write_concern.acknowledged? + raise Error::UnsupportedOption.hint_error(unacknowledged_write: true) + end + operation.txn_num = txn_num + operation.execute_with_connection(connection, context: context) end - - Operation::Update.new( - updates: [ update_doc ], - db_name: collection.database.name, - coll_name: collection.name, - write_concern: write_concern, - bypass_document_validation: !!opts[:bypass_document_validation], - session: session, - txn_num: txn_num, - let: opts[:let], - comment: opts[:comment], - ).execute_with_connection(connection, context: context) end end end diff --git a/lib/mongo/database.rb b/lib/mongo/database.rb index 5fb69bc09a..1f145fd6ab 100644 --- a/lib/mongo/database.rb +++ b/lib/mongo/database.rb @@ -74,7 +74,8 @@ class Database :server_selector, :read_concern, :write_concern, - :encrypted_fields_map + :encrypted_fields_map, + :tracer # @return [ Mongo::Server ] Get the primary server from the cluster. def_delegators :cluster, @@ -267,10 +268,12 @@ def command(operation, opts = {}) # @option opts :session [ Session ] The session to use for this command. # @option opts [ Object ] :comment A user-provided # comment to attach to this command. - # @option options [ Integer ] :timeout_ms The operation timeout in milliseconds. + # @option opts [ Integer ] :timeout_ms The operation timeout in milliseconds. # Must be a non-negative integer. An explicit value of 0 means infinite. # The default value is unset which means the value is inherited from # the database or the client. + # @option opts :op_name [ String | nil ] The name of the operation for + # tracing purposes. # # @return [ Hash ] The result of the command execution. # @api private @@ -290,14 +293,18 @@ def read_command(operation, opts = {}) session: session, operation_timeouts: operation_timeouts(opts) ) - read_with_retry(session, preference, context) do |server| - Operation::Command.new( - selector: operation.dup, - db_name: name, - read: preference, - session: session, - comment: opts[:comment], - ).execute(server, context: context) + operation = Operation::Command.new( + selector: operation.dup, + db_name: name, + read: preference, + session: session, + comment: opts[:comment], + ) + op_name = opts[:op_name] || 'command' + tracer.trace_operation(operation, context, op_name: op_name) do + read_with_retry(session, preference, context) do |server| + operation.execute(server, context: context) + end end end end diff --git a/lib/mongo/database/view.rb b/lib/mongo/database/view.rb index 3c0bcaaf89..d19c839a98 100644 --- a/lib/mongo/database/view.rb +++ b/lib/mongo/database/view.rb @@ -45,6 +45,8 @@ class View # @return [ Collection ] collection The command collection. attr_reader :collection + def_delegators :@database, :tracer + # Get all the names of the non-system collections in the database. # # @note The set of returned collection names depends on the version of @@ -214,27 +216,30 @@ def collections_info(session, server_selector, options = {}, &block) session: session, operation_timeouts: operation_timeouts(options) ) - cursor = read_with_retry_cursor(session, server_selector, self, context: context) do |server| - # TODO take description from the connection used to send the query - # once https://jira.mongodb.org/browse/RUBY-1601 is fixed. - description = server.description - send_initial_query(server, session, context, options) - end - # On 3.0+ servers, we get just the collection names. - # On 2.6 server, we get collection names prefixed with the database - # name. We need to filter system collections out here because - # in the caller we don't know which server version executed the - # command and thus what the proper filtering logic should be - # (it is valid for collection names to have dots, thus filtering out - # collections named system.* here for 2.6 servers would actually - # filter out collections in the system database). - if description.server_version_gte?('3.0') - cursor.reject do |doc| - doc['name'].start_with?('system.') || doc['name'].include?('$') + op = initial_query_op(session, options) + tracer.trace_operation(op, context, op_name: 'listCollections') do + cursor = read_with_retry_cursor(session, server_selector, self, context: context) do |server| + # TODO take description from the connection used to send the query + # once https://jira.mongodb.org/browse/RUBY-1601 is fixed. + description = server.description + send_initial_query(server, session, context, options) end - else - cursor.reject do |doc| - doc['name'].start_with?("#{database.name}.system") || doc['name'].include?('$') + # On 3.0+ servers, we get just the collection names. + # On 2.6 server, we get collection names prefixed with the database + # name. We need to filter system collections out here because + # in the caller we don't know which server version executed the + # command and thus what the proper filtering logic should be + # (it is valid for collection names to have dots, thus filtering out + # collections named system.* here for 2.6 servers would actually + # filter out collections in the system database). + if description.server_version_gte?('3.0') + cursor.reject do |doc| + doc['name'].start_with?('system.') || doc['name'].include?('$') + end + else + cursor.reject do |doc| + doc['name'].start_with?("#{database.name}.system") || doc['name'].include?('$') + end end end end diff --git a/lib/mongo/index/view.rb b/lib/mongo/index/view.rb index 4e8c41b742..7f9d51e281 100644 --- a/lib/mongo/index/view.rb +++ b/lib/mongo/index/view.rb @@ -45,6 +45,7 @@ class View def_delegators :@collection, :cluster, :database, :read_preference, :write_concern, :client def_delegators :cluster, :next_primary + def_delegators :client, :tracer # The index key field. # @@ -221,9 +222,7 @@ def create_many(*models) end client.with_session(@options.merge(options)) do |session| - server = next_primary(nil, session) - - indexes = normalize_models(models, server) + indexes = normalize_models(models) indexes.each do |index| if index[:bucketSize] || index['bucketSize'] client.log_warn("Haystack indexes (bucketSize index option) are deprecated as of MongoDB 4.4") @@ -244,7 +243,11 @@ def create_many(*models) session: session, operation_timeouts: operation_timeouts(options) ) - Operation::CreateIndex.new(spec).execute(server, context: context) + operation = Operation::CreateIndex.new(spec) + tracer.trace_operation(operation, context, op_name: 'createIndexes') do + server = next_primary(nil, session) + operation.execute(server, context: context) + end end end @@ -283,16 +286,18 @@ def each(&block) session: session, operation_timeouts: operation_timeouts(@options) ) - - cursor = read_with_retry_cursor(session, ServerSelector.primary, self, context: context) do |server| - send_initial_query(server, session, context) - end - if block_given? - cursor.each do |doc| - yield doc + op = initial_query_op(session) + tracer.trace_operation(op, context, op_name: 'listIndexes') do + cursor = read_with_retry_cursor(session, ServerSelector.primary, self, context: context) do |server| + send_initial_query(op, server, session, context) + end + if block_given? + cursor.each do |doc| + yield doc + end + else + cursor.to_enum end - else - cursor.to_enum end end @@ -359,13 +364,17 @@ def drop_by_name(name, opts = {}) write_concern: write_concern, } spec[:comment] = opts[:comment] unless opts[:comment].nil? - server = next_primary(nil, session) context = Operation::Context.new( client: client, session: session, operation_timeouts: operation_timeouts(opts) ) - Operation::DropIndex.new(spec).execute(server, context: context) + op = Operation::DropIndex.new(spec) + op_name = name == Index::ALL ? 'dropIndexes' : 'dropIndex' + tracer.trace_operation(op, context, op_name: op_name) do + server = next_primary(nil, session) + op.execute(server, context: context) + end end end @@ -394,7 +403,7 @@ def normalize_keys(spec) Options::Mapper.transform_keys_to_strings(spec) end - def normalize_models(models, server) + def normalize_models(models) models.map do |model| # Transform options first which gives us a mutable hash Options::Mapper.transform(model, OPTIONS).tap do |model| @@ -403,12 +412,12 @@ def normalize_models(models, server) end end - def send_initial_query(server, session, context) + def send_initial_query(op, server, session, context) if server.load_balancer? connection = server.pool.check_out(context: context) - initial_query_op(session).execute_with_connection(connection, context: context) + op.execute_with_connection(connection, context: context) else - initial_query_op(session).execute(server, context: context) + op.execute(server, context: context) end end end diff --git a/lib/mongo/operation/create.rb b/lib/mongo/operation/create.rb index efd10e1f7d..c3fb72b704 100644 --- a/lib/mongo/operation/create.rb +++ b/lib/mongo/operation/create.rb @@ -28,6 +28,10 @@ module Operation class Create include Specifiable include OpMsgExecutable + + def encrypted_fields=(value) + @spec[:encrypted_fields] = value + end end end end diff --git a/lib/mongo/operation/insert/op_msg.rb b/lib/mongo/operation/insert/op_msg.rb index 39b299ef76..631dbcad19 100644 --- a/lib/mongo/operation/insert/op_msg.rb +++ b/lib/mongo/operation/insert/op_msg.rb @@ -34,8 +34,12 @@ class OpMsg < OpMsgBase private def get_result(connection, context, options = {}) - # This is a Mongo::Operation::Insert::Result - Result.new(*dispatch_message(connection, context), @ids, context: context) + message = build_message(connection, context) + connection.tracer.trace_command(message, context, connection) do + result = Result.new(*dispatch_message(message, connection, context), @ids, context: context) + yield result + validate_result(result, connection, context) + end end def selector(connection) diff --git a/lib/mongo/operation/shared/executable.rb b/lib/mongo/operation/shared/executable.rb index 041e4d1e5b..1349c542b0 100644 --- a/lib/mongo/operation/shared/executable.rb +++ b/lib/mongo/operation/shared/executable.rb @@ -45,7 +45,7 @@ def do_execute(connection, context, options = {}) add_error_labels(connection, context) do check_for_network_error do add_server_diagnostics(connection) do - get_result(connection, context, options).tap do |result| + get_result(connection, context, options) do |result| if session if session.in_transaction? && connection.description.load_balancer? @@ -92,9 +92,7 @@ def execute(connection, context:, options: {}) end end - do_execute(connection, context, options).tap do |result| - validate_result(result, connection, context) - end + do_execute(connection, context, options) end private @@ -104,12 +102,16 @@ def result_class end def get_result(connection, context, options = {}) - result_class.new(*dispatch_message(connection, context, options), context: context, connection: connection) + message = build_message(connection, context) + connection.tracer.trace_command(message, context, connection) do + result = result_class.new(*dispatch_message(message, connection, context, options), context: context, connection: connection) + yield result + validate_result(result, connection, context) + end end # Returns a Protocol::Message or nil as reply. - def dispatch_message(connection, context, options = {}) - message = build_message(connection, context) + def dispatch_message(message, connection, context, options = {}) message = message.maybe_encrypt(connection, context) reply = connection.dispatch([ message ], context, options) [reply, connection.description, connection.global_id] diff --git a/lib/mongo/operation/shared/specifiable.rb b/lib/mongo/operation/shared/specifiable.rb index afc799f46e..dd2e37369e 100644 --- a/lib/mongo/operation/shared/specifiable.rb +++ b/lib/mongo/operation/shared/specifiable.rb @@ -233,7 +233,7 @@ def documents # # @since 2.0.0 def coll_name - spec.fetch(COLL_NAME) + spec[COLL_NAME] end # The id of the cursor created on the server. @@ -526,6 +526,10 @@ def txn_num @spec[:txn_num] end + def txn_num=(num) + @spec[:txn_num] = num + end + # The command. # # @return [ Hash ] The command. diff --git a/lib/mongo/server.rb b/lib/mongo/server.rb index c00285034e..edf2eb3b4f 100644 --- a/lib/mongo/server.rb +++ b/lib/mongo/server.rb @@ -218,7 +218,8 @@ def compressor # @api private def_delegators :cluster, :monitor_app_metadata, - :push_monitor_app_metadata + :push_monitor_app_metadata, + :tracer def_delegators :features, :check_driver_support! diff --git a/lib/mongo/server/connection.rb b/lib/mongo/server/connection.rb index f9874764cf..4c0024c529 100644 --- a/lib/mongo/server/connection.rb +++ b/lib/mongo/server/connection.rb @@ -139,6 +139,8 @@ def initialize(server, options = {}) # across all connections. attr_reader :global_id + def_delegators :server, :tracer + # The connection pool from which this connection was created. # May be nil. # @@ -388,6 +390,17 @@ def record_checkin! self end + def transport + return nil if @socket.nil? + + case @socket + when Mongo::Socket::Unix + :unix + else + :tcp + end + end + private def deliver(message, client, options = {}) diff --git a/lib/mongo/session.rb b/lib/mongo/session.rb index be9c1f2a42..2a7dfea4ec 100644 --- a/lib/mongo/session.rb +++ b/lib/mongo/session.rb @@ -130,6 +130,8 @@ def snapshot? # @since 2.5.0 attr_reader :operation_time + def_delegators :client, :tracer + # Sets the dirty state to the given value for the underlying server # session. If there is no server session, this does nothing. # @@ -622,6 +624,7 @@ def start_transaction(options = nil) @state = STARTING_TRANSACTION_STATE @already_committed = false + tracer.start_transaction_span(self) # This method has no explicit return value. # We could return nil here but true indicates to the user that the @@ -701,9 +704,14 @@ def commit_transaction(options=nil) txn_num: txn_num, write_concern: write_concern, } - Operation::Command.new(spec).execute_with_connection(connection, context: context) + operation = Operation::Command.new(spec) + tracer.trace_operation(operation, context, op_name: 'commitTransaction') do + operation.execute_with_connection(connection, context: context) + end end end + # Finish the transaction span before changing state + tracer.finish_transaction_span(self) ensure @state = TRANSACTION_COMMITTED_STATE @committing_transaction = false @@ -758,24 +766,31 @@ def abort_transaction(options = nil) ending_transaction: true, context: context, ) do |connection, txn_num, context| begin - Operation::Command.new( + operation = Operation::Command.new( selector: { abortTransaction: 1 }, db_name: 'admin', session: self, txn_num: txn_num - ).execute_with_connection(connection, context: context) + ) + tracer.trace_operation(operation, context, op_name: 'abortTransaction') do + operation.execute_with_connection(connection, context: context) + end ensure unpin end end end + # Finish the transaction span before changing state + tracer.finish_transaction_span(self) @state = TRANSACTION_ABORTED_STATE rescue Mongo::Error::InvalidTransactionOperation raise rescue Mongo::Error + tracer.finish_transaction_span(self) @state = TRANSACTION_ABORTED_STATE rescue Exception + tracer.finish_transaction_span(self) @state = TRANSACTION_ABORTED_STATE raise ensure diff --git a/lib/mongo/tracing.rb b/lib/mongo/tracing.rb new file mode 100644 index 0000000000..4c3ce2fc04 --- /dev/null +++ b/lib/mongo/tracing.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# Copyright (C) 2025-present MongoDB Inc. +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Mongo + # Provides OpenTelemetry tracing capabilities for MongoDB operations. + module Tracing + # Creates a new OpenTelemetry tracer for instrumenting MongoDB operations. + # + # @param enabled [Boolean, nil] Whether tracing is enabled. Defautl to nil, which + # means it will check the environment variable OTEL_RUBY_INSTRUMENTATION_MONGODB_ENABLED. + # See +Mongo::Tracing::OpenTelemetry::Tracer+ for details. + # @param query_text_max_length [Integer, nil] Maximum length for captured query text. Defaults to nil, + # which means it will check the environment variable OTEL_RUBY_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH. + # See +Mongo::Tracing::OpenTelemetry::Tracer+ for details. + # @param otel_tracer [OpenTelemetry::Trace::Tracer, nil] Custom OpenTelemetry tracer instance. + # + # @return [Mongo::Tracing::OpenTelemetry::Tracer] Configured tracer instance. + def create_tracer(enabled: nil, query_text_max_length: nil, otel_tracer: nil) + OpenTelemetry::Tracer.new( + enabled: enabled, + query_text_max_length: query_text_max_length, + otel_tracer: otel_tracer + ) + end + module_function :create_tracer + end +end + +require 'mongo/tracing/open_telemetry' diff --git a/lib/mongo/tracing/open_telemetry.rb b/lib/mongo/tracing/open_telemetry.rb new file mode 100644 index 0000000000..702d2885c4 --- /dev/null +++ b/lib/mongo/tracing/open_telemetry.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Copyright (C) 2025-present MongoDB Inc. +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Mongo + module Tracing + module OpenTelemetry + end + end +end + +require 'mongo/tracing/open_telemetry/command_tracer' +require 'mongo/tracing/open_telemetry/operation_tracer' +require 'mongo/tracing/open_telemetry/tracer' diff --git a/lib/mongo/tracing/open_telemetry/command_tracer.rb b/lib/mongo/tracing/open_telemetry/command_tracer.rb new file mode 100644 index 0000000000..28938501c7 --- /dev/null +++ b/lib/mongo/tracing/open_telemetry/command_tracer.rb @@ -0,0 +1,315 @@ +# frozen_string_literal: true + +# Copyright (C) 2025-present MongoDB Inc. +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Mongo + module Tracing + module OpenTelemetry + # CommandTracer is responsible for tracing MongoDB server commands using OpenTelemetry. + # + # @api private + class CommandTracer + # Initializes a new CommandTracer. + # + # @param otel_tracer [ OpenTelemetry::Trace::Tracer ] the OpenTelemetry tracer. + # @param parent_tracer [ Mongo::Tracing::OpenTelemetry::Tracer ] the parent tracer + # for accessing shared context maps. + # @param query_text_max_length [ Integer ] maximum length for captured query text. + # Defaults to 0 (no query text capture). + def initialize(otel_tracer, parent_tracer, query_text_max_length: 0) + @otel_tracer = otel_tracer + @parent_tracer = parent_tracer + @query_text_max_length = query_text_max_length + end + + # Starts a span for a MongoDB command. + # + # @param message [ Mongo::Protocol::Message ] the command message. + # @param operation_context [ Mongo::Operation::Context ] the operation context. + # @param connection [ Mongo::Server::Connection ] the connection. + def start_span(message, operation_context, connection); end + + # Trace a MongoDB command. + # + # Creates an OpenTelemetry span for the command, capturing attributes such as + # command name, database name, collection name, server address, connection IDs, + # and optionally query text. The span is automatically nested under the current + # operation span and is finished when the command completes or fails. + # + # @param message [ Mongo::Protocol::Message ] the command message to trace. + # @param _operation_context [ Mongo::Operation::Context ] the context of the operation. + # @param connection [ Mongo::Server::Connection ] the connection used to send the command. + # + # @yield the block representing the command to be traced. + # + # @return [ Object ] the result of the command. + # rubocop:disable Lint/RescueException + def trace_command(message, _operation_context, connection) + # Commands should always be nested under their operation span, not directly under + # the transaction span. Don't pass with_parent to use automatic parent resolution + # from the currently active span (the operation span). + span = create_command_span(message, connection) + ::OpenTelemetry::Trace.with_span(span) do |s, c| + yield.tap do |result| + process_command_result(result, cursor_id(message), c, s) + end + end + rescue Exception => e + handle_command_exception(span, e) + raise e + ensure + span&.finish + end + # rubocop:enable Lint/RescueException + + private + + # Creates a span for a command. + # + # @param message [ Mongo::Protocol::Message ] the command message. + # @param connection [ Mongo::Server::Connection ] the connection. + # + # @return [ OpenTelemetry::Trace::Span ] the created span. + def create_command_span(message, connection) + @otel_tracer.start_span( + command_name(message), + attributes: span_attributes(message, connection), + kind: :client + ) + end + + # Processes the command result and updates span attributes. + # + # @param result [ Object ] the command result. + # @param cursor_id [ Integer | nil ] the cursor ID. + # @param context [ OpenTelemetry::Context ] the context. + # @param span [ OpenTelemetry::Trace::Span ] the current span. + def process_command_result(result, cursor_id, context, span) + process_cursor_context(result, cursor_id, context, span) + maybe_trace_error(result, span) + end + + # Handles exceptions that occur during command execution. + # + # @param span [ OpenTelemetry::Trace::Span | nil ] the span. + # @param exception [ Exception ] the exception that occurred. + def handle_command_exception(span, exception) + return unless span + + if exception.is_a?(Mongo::Error::OperationFailure) + span.set_attribute('db.response.status_code', exception.code.to_s) + end + span.record_exception(exception) + span.status = ::OpenTelemetry::Trace::Status.error("Unhandled exception of type: #{exception.class}") + end + + # Builds span attributes for the command. + # + # @param message [ Mongo::Protocol::Message ] the command message. + # @param connection [ Mongo::Server::Connection ] the connection. + # + # @return [ Hash ] OpenTelemetry span attributes following MongoDB semantic conventions. + def span_attributes(message, connection) + base_attributes(message) + .merge(connection_attributes(connection)) + .merge(session_attributes(message)) + .compact + end + + # Returns base database and command attributes. + # + # @param message [ Mongo::Protocol::Message ] the command message. + # + # @return [ Hash ] base span attributes. + def base_attributes(message) + { + 'db.system' => 'mongodb', + 'db.namespace' => database(message), + 'db.collection.name' => collection_name(message), + 'db.command.name' => command_name(message), + 'db.query.summary' => query_summary(message), + 'db.query.text' => query_text(message) + } + end + + # Returns connection-related attributes. + # + # @param connection [ Mongo::Server::Connection ] the connection. + # + # @return [ Hash ] connection span attributes. + def connection_attributes(connection) + { + 'server.port' => connection.address.port, + 'server.address' => connection.address.host, + 'network.transport' => connection.transport.to_s, + 'db.mongodb.server_connection_id' => connection.server.description.server_connection_id, + 'db.mongodb.driver_connection_id' => connection.id + } + end + + # Returns session and transaction attributes. + # + # @param message [ Mongo::Protocol::Message ] the command message. + # + # @return [ Hash ] session span attributes. + def session_attributes(message) + { + 'db.mongodb.cursor_id' => cursor_id(message), + 'db.mongodb.lsid' => lsid(message), + 'db.mongodb.txn_number' => txn_number(message) + } + end + + # Processes cursor context from the command result. + # + # @param result [ Object ] the command result. + # @param _cursor_id [ Integer | nil ] the cursor ID (unused). + # @param _context [ OpenTelemetry::Context ] the context (unused). + # @param span [ OpenTelemetry::Trace::Span ] the current span. + def process_cursor_context(result, _cursor_id, _context, span) + return unless result.has_cursor_id? && result.cursor_id.positive? + + span.set_attribute('db.mongodb.cursor_id', result.cursor_id) + end + + # Records error status code if the command failed. + # + # @param result [ Object ] the command result. + # @param span [ OpenTelemetry::Trace::Span ] the current span. + def maybe_trace_error(result, span) + return if result.successful? + + span.set_attribute('db.response.status_code', result.error.code.to_s) + end + + # Generates a summary string for the query. + # + # @param message [ Mongo::Protocol::Message ] the command message. + # + # @return [ String ] summary in format "command_name db.collection" or "command_name db". + def query_summary(message) + if (coll_name = collection_name(message)) + "#{command_name(message)} #{database(message)}.#{coll_name}" + else + "#{command_name(message)} #{database(message)}" + end + end + + # Extracts the collection name from the command message. + # + # @param message [ Mongo::Protocol::Message ] the command message. + # + # @return [ String | nil ] the collection name, or nil if not applicable. + def collection_name(message) + case message.documents.first.keys.first + when 'getMore' + message.documents.first['collection'].to_s + when 'listCollections', 'listDatabases', 'commitTransaction', 'abortTransaction' + nil + else + value = message.documents.first.values.first + # Return nil if the value is not a string (e.g., for admin commands that have numeric values) + value.is_a?(String) ? value : nil + end + end + + # Extracts the command name from the message. + # + # @param message [ Mongo::Protocol::Message ] the command message. + # + # @return [ String ] the command name. + def command_name(message) + message.documents.first.keys.first.to_s + end + + # Extracts the database name from the message. + # + # @param message [ Mongo::Protocol::Message ] the command message. + # + # @return [ String ] the database name. + def database(message) + message.documents.first['$db'].to_s + end + + # Checks if query text capture is enabled. + # + # @return [ Boolean ] true if query text should be captured. + def query_text? + @query_text_max_length.positive? + end + + # Extracts the cursor ID from getMore commands. + # + # @param message [ Mongo::Protocol::Message ] the command message. + # + # @return [ Integer | nil ] the cursor ID, or nil if not a getMore command. + def cursor_id(message) + return unless command_name(message) == 'getMore' + + message.documents.first['getMore'].value + end + + # Extracts the logical session ID from the command. + # + # @param message [ Mongo::Protocol::Message ] the command message. + # + # @return [ BSON::Binary | nil ] the session ID, or nil if not present. + def lsid(message) + lsid_doc = message.documents.first['lsid'] + return unless lsid_doc + + lsid_doc['id'].to_uuid + end + + # Extracts the transaction number from the command. + # + # @param message [ Mongo::Protocol::Message ] the command message. + # + # @return [ Integer | nil ] the transaction number, or nil if not present. + def txn_number(message) + txn_num = message.documents.first['txnNumber'] + return unless txn_num + + txn_num.value + end + + # Keys to exclude from query text capture. + EXCLUDED_KEYS = %w[lsid $db $clusterTime signature].freeze + + # Ellipsis for truncated query text. + ELLIPSES = '...' + + # Extracts and formats the query text from the command. + # + # @param message [ Mongo::Protocol::Message ] the command message. + # + # @return [ String | nil ] JSON representation of the command, truncated if necessary, or nil if disabled. + def query_text(message) + return unless query_text? + + text = message + .payload['command'] + .reject { |key, _| EXCLUDED_KEYS.include?(key) } + .to_json + if text.length > @query_text_max_length + "#{text[0...@query_text_max_length]}#{ELLIPSES}" + else + text + end + end + end + end + end +end diff --git a/lib/mongo/tracing/open_telemetry/operation_tracer.rb b/lib/mongo/tracing/open_telemetry/operation_tracer.rb new file mode 100644 index 0000000000..e5c0729a54 --- /dev/null +++ b/lib/mongo/tracing/open_telemetry/operation_tracer.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +# Copyright (C) 2025-present MongoDB Inc. +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Mongo + module Tracing + module OpenTelemetry + # OperationTracer is responsible for tracing MongoDB driver operations using OpenTelemetry. + # + # @api private + class OperationTracer + extend Forwardable + + def_delegators :@parent_tracer, + :cursor_context_map, + :parent_context_for, + :transaction_context_map, + :transaction_map_key + + # Initializes a new OperationTracer. + # + # @param otel_tracer [ OpenTelemetry::Trace::Tracer ] the OpenTelemetry tracer. + # @param parent_tracer [ Mongo::Tracing::OpenTelemetry::Tracer ] the parent tracer + # for accessing shared context maps. + def initialize(otel_tracer, parent_tracer) + @otel_tracer = otel_tracer + @parent_tracer = parent_tracer + end + + # Trace a MongoDB operation. + # + # Creates an OpenTelemetry span for the operation, capturing attributes such as + # database name, collection name, operation name, and cursor ID. The span is finished + # automatically when the operation completes or fails. + # + # @param operation [ Mongo::Operation ] the MongoDB operation to trace. + # @param operation_context [ Mongo::Operation::Context ] the context of the operation. + # @param op_name [ String | nil ] an optional name for the operation. If nil, the + # operation class name is used. + # + # @yield the block representing the operation to be traced. + # + # @return [ Object ] the result of the operation. + # + # rubocop:disable Lint/RescueException + def trace_operation(operation, operation_context, op_name: nil, &block) + span = create_operation_span(operation, operation_context, op_name) + execute_with_span(span, operation, &block) + rescue Exception => e + handle_span_exception(span, e) + raise e + ensure + span&.finish + end + # rubocop:enable Lint/RescueException + + private + + # Creates an OpenTelemetry span for the operation. + # + # @param operation [ Mongo::Operation ] the operation. + # @param operation_context [ Mongo::Operation::Context ] the operation context. + # @param op_name [ String | nil ] optional operation name. + # + # @return [ OpenTelemetry::Trace::Span ] the created span. + def create_operation_span(operation, operation_context, op_name) + parent_context = parent_context_for(operation_context, operation.cursor_id) + @otel_tracer.start_span( + operation_span_name(operation, op_name), + attributes: span_attributes(operation, op_name), + with_parent: parent_context, + kind: :client + ) + end + + # Executes the operation block within the span context. + # + # @param span [ OpenTelemetry::Trace::Span ] the span. + # @param operation [ Mongo::Operation ] the operation. + # + # @yield the block to execute. + # + # @return [ Object ] the result of the block. + def execute_with_span(span, operation) + ::OpenTelemetry::Trace.with_span(span) do |s, c| + yield.tap do |result| + process_cursor_context(result, operation.cursor_id, c, s) + end + end + end + + # Handles exception for the span. + # + # @param span [ OpenTelemetry::Trace::Span ] the span. + # @param exception [ Exception ] the exception. + def handle_span_exception(span, exception) + return unless span + + span.record_exception(exception) + span.status = ::OpenTelemetry::Trace::Status.error( + "Unhandled exception of type: #{exception.class}" + ) + end + + # Returns the operation name from the provided name or operation class. + # + # @param operation [ Mongo::Operation ] the operation. + # @param op_name [ String | nil ] optional operation name. + # + # @return [ String ] the operation name in lowercase. + def operation_name(operation, op_name = nil) + op_name || operation.class.name.split('::').last.downcase + end + + # Builds span attributes for the operation. + # + # @param operation [ Mongo::Operation ] the operation. + # @param op_name [ String | nil ] optional operation name. + # + # @return [ Hash ] OpenTelemetry span attributes following MongoDB semantic conventions. + def span_attributes(operation, op_name) + { + 'db.system' => 'mongodb', + 'db.namespace' => operation.db_name.to_s, + 'db.collection.name' => collection_name(operation), + 'db.operation.name' => operation_name(operation, op_name), + 'db.operation.summary' => operation_span_name(operation, op_name), + 'db.mongodb.cursor_id' => operation.cursor_id, + }.compact + end + + # Processes cursor context after operation execution. + # + # Updates the cursor context map based on the result. Removes closed cursors + # and stores context for newly created cursors. + # + # @param result [ Object ] the operation result. + # @param cursor_id [ Integer | nil ] the cursor ID before the operation. + # @param context [ OpenTelemetry::Context ] the OpenTelemetry context. + # @param span [ OpenTelemetry::Trace::Span ] the current span. + def process_cursor_context(result, cursor_id, context, span) + return unless result.is_a?(Cursor) + + if result.id.zero? + # If the cursor is closed, remove it from the context map. + cursor_context_map.delete(cursor_id) + elsif result.id && cursor_id.nil? + # New cursor created, store its context. + cursor_context_map[result.id] = context + span.set_attribute('db.mongodb.cursor_id', result.id) + end + end + + # Extracts the collection name from the operation. + # + # @param operation [ Mongo::Operation ] the operation. + # + # @return [ String | nil ] the collection name, or nil if not applicable. + def collection_name(operation) + return operation.coll_name.to_s if operation.respond_to?(:coll_name) && operation.coll_name + + extract_collection_from_spec(operation) + end + + # Extracts collection name from operation spec based on operation type. + # + # @param operation [ Mongo::Operation ] the operation. + # + # @return [ String | nil ] the collection name, or nil if not found. + def extract_collection_from_spec(operation) + collection_key = collection_key_for_operation(operation) + return nil unless collection_key + + value = if collection_key == :first_value + operation.spec[:selector].values.first + else + operation.spec[:selector][collection_key] + end + value&.to_s + end + + # Returns the collection key for a given operation type. + # + # @param operation [ Mongo::Operation ] the operation. + # + # @return [ Symbol | nil ] the collection key symbol or nil. + def collection_key_for_operation(operation) + case operation + when Operation::Aggregate then :aggregate + when Operation::Count then :count + when Operation::Create then :create + when Operation::Distinct then :distinct + when Operation::Drop then :drop + when Operation::WriteCommand then :first_value + end + end + + # Generates the span name for the operation. + # + # @param operation [ Mongo::Operation ] the operation. + # @param op_name [ String | nil ] optional operation name. + # + # @return [ String ] span name in format "operation_name db.collection" or "operation_name db". + def operation_span_name(operation, op_name = nil) + coll_name = collection_name(operation) + if coll_name && !coll_name.empty? + "#{operation_name(operation, op_name)} #{operation.db_name}.#{coll_name}" + else + "#{operation_name(operation, op_name)} #{operation.db_name}" + end + end + end + end + end +end diff --git a/lib/mongo/tracing/open_telemetry/tracer.rb b/lib/mongo/tracing/open_telemetry/tracer.rb new file mode 100644 index 0000000000..2f89f4ff65 --- /dev/null +++ b/lib/mongo/tracing/open_telemetry/tracer.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +# Copyright (C) 2025-present MongoDB Inc. +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Mongo + module Tracing + module OpenTelemetry + # OpenTelemetry tracer for MongoDB operations and commands. + # @api private + class Tracer + # @return [ OpenTelemetry::Trace::Tracer ] the OpenTelemetry tracer implementation + # used to create spans for MongoDB operations and commands. + # + # @api private + attr_reader :otel_tracer + + # Initializes a new OpenTelemetry tracer. + # + # @param enabled [ Boolean | nil ] whether OpenTelemetry is enabled or not. + # Defaults to nil, which means it will check the environment variable + # OTEL_RUBY_INSTRUMENTATION_MONGODB_ENABLED (values: true/1/yes). If the + # environment variable is not set, OpenTelemetry will be disabled by default. + # @param query_text_max_length [ Integer | nil ] maximum length for captured query text. + # Defaults to nil, which means it will check the environment variable + # OTEL_RUBY_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH. If the environment variable is not set, + # the query text will not be captured. + # @param otel_tracer [ OpenTelemetry::Trace::Tracer | nil ] the OpenTelemetry tracer + # implementation to use. Defaults to nil, which means it will use the default tracer + # from OpenTelemetry's tracer provider. + def initialize(enabled: nil, query_text_max_length: nil, otel_tracer: nil) + @enabled = if enabled.nil? + %w[true 1 yes].include?(ENV['OTEL_RUBY_INSTRUMENTATION_MONGODB_ENABLED']&.downcase) + else + enabled + end + @query_text_max_length = if query_text_max_length.nil? + ENV['OTEL_RUBY_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH'].to_i + else + query_text_max_length + end + @otel_tracer = otel_tracer || initialize_tracer + @operation_tracer = OperationTracer.new(@otel_tracer, self) + @command_tracer = CommandTracer.new(@otel_tracer, self, query_text_max_length: @query_text_max_length) + end + + # Whether OpenTelemetry is enabled or not. + # + # # @return [Boolean] true if OpenTelemetry is enabled, false otherwise. + def enabled? + @enabled + end + + # Trace a MongoDB operation. + # + # @param operation [Mongo::Operation] The MongoDB operation to trace. + # @param operation_context [Mongo::Operation::Context] The context of the operation. + # @param op_name [String, nil] An optional name for the operation. + # @yield The block representing the operation to be traced. + # @return [Object] The result of the operation. + def trace_operation(operation, operation_context, op_name: nil, &block) + return yield unless enabled? + + @operation_tracer.trace_operation(operation, operation_context, op_name: op_name, &block) + end + + # Trace a MongoDB command. + # + # @param message [Mongo::Protocol::Message] The MongoDB command message to trace. + # @param operation_context [Mongo::Operation::Context] The context of the operation. + # @param connection [Mongo::Server::Connection] The connection used to send the command + # @yield The block representing the command to be traced. + # @return [Object] The result of the command. + def trace_command(message, operation_context, connection, &block) + return yield unless enabled? + + @command_tracer.trace_command(message, operation_context, connection, &block) + end + + # Start a transaction span and activate its context. + # + # @param session [Mongo::Session] The session starting the transaction. + def start_transaction_span(session) + return unless enabled? + + key = transaction_map_key(session) + return unless key + + # Create the transaction span with minimal attributes + span = @otel_tracer.start_span( + 'transaction', + attributes: { 'db.system' => 'mongodb' }, + kind: :client + ) + + # Create a context containing this span + context = ::OpenTelemetry::Trace.context_with_span(span) + + # Activate the context and store the token for later detachment + token = ::OpenTelemetry::Context.attach(context) + + # Store span, token, and context for later retrieval + transaction_span_map[key] = span + transaction_token_map[key] = token + transaction_context_map[key] = context + end + + # Finish a transaction span and deactivate its context. + # + # @param session [Mongo::Session] The session finishing the transaction. + def finish_transaction_span(session) + return unless enabled? + + key = transaction_map_key(session) + return unless key + + span = transaction_span_map.delete(key) + token = transaction_token_map.delete(key) + transaction_context_map.delete(key) + + return unless span && token + + begin + span.finish + ensure + ::OpenTelemetry::Context.detach(token) + end + end + + # Returns the cursor context map for tracking cursor-related OpenTelemetry contexts. + # + # @return [ Hash ] map of cursor IDs to OpenTelemetry contexts. + def cursor_context_map + @cursor_context_map ||= {} + end + + # Generates a unique key for cursor tracking in the context map. + # + # @param session [ Mongo::Session ] the session associated with the cursor. + # @param cursor_id [ Integer ] the cursor ID. + # + # @return [ String | nil ] unique key combining session ID and cursor ID, or nil if either is nil. + def cursor_map_key(session, cursor_id) + return if cursor_id.nil? || session.nil? + + "#{session.session_id['id'].to_uuid}-#{cursor_id}" + end + + # Determines the parent OpenTelemetry context for an operation. + # + # Returns the transaction context if the operation is part of a transaction, + # otherwise returns nil. Cursor-based context nesting is not currently implemented. + # + # @param operation_context [ Mongo::Operation::Context ] the operation context. + # @param cursor_id [ Integer ] the cursor ID, if applicable. + # + # @return [ OpenTelemetry::Context | nil ] parent context or nil. + def parent_context_for(operation_context, cursor_id) + if (key = transaction_map_key(operation_context.session)) + transaction_context_map[key] + elsif (_key = cursor_map_key(operation_context.session, cursor_id)) + # We return nil here unless we decide how to nest cursor operations. + nil + end + end + + # Returns the transaction context map for tracking active transaction contexts. + # + # @return [ Hash ] map of transaction keys to OpenTelemetry contexts. + def transaction_context_map + @transaction_context_map ||= {} + end + + # Returns the transaction span map for tracking active transaction spans. + # + # @return [ Hash ] map of transaction keys to OpenTelemetry spans. + def transaction_span_map + @transaction_span_map ||= {} + end + + # Returns the transaction token map for tracking context attachment tokens. + # + # @return [ Hash ] map of transaction keys to OpenTelemetry context tokens. + def transaction_token_map + @transaction_token_map ||= {} + end + + # Generates a unique key for transaction tracking. + # + # Returns nil for implicit sessions or sessions not in a transaction. + # + # @param session [ Mongo::Session ] the session. + # + # @return [ String | nil ] unique key combining session ID and transaction number, or nil. + def transaction_map_key(session) + return if session.nil? || session.implicit? || !session.in_transaction? + + "#{session.session_id['id'].to_uuid}-#{session.txn_num}" + end + + private + + def initialize_tracer + if enabled? + # Obtain the proper tracer from OpenTelemetry's tracer provider. + ::OpenTelemetry.tracer_provider.tracer( + 'mongo-ruby-driver', + Mongo::VERSION + ) + else + # No-op tracer when OpenTelemetry is not enabled. + ::OpenTelemetry::Trace::Tracer.new + end + end + end + end + end +end diff --git a/spec/lite_spec_helper.rb b/spec/lite_spec_helper.rb index 486d9c4235..28ce8bd9f0 100644 --- a/spec/lite_spec_helper.rb +++ b/spec/lite_spec_helper.rb @@ -94,10 +94,11 @@ module Mrss require 'support/json_ext_formatter' require 'support/sdam_formatter_integration' require 'support/background_thread_registry' +require 'support/tracing' require 'mrss/session_registry' require 'support/local_resource_registry' -if SpecConfig.instance.mri? && !SpecConfig.instance.windows? +if SpecConfig.instance.mri? && (SpecConfig.instance.linux? || SpecConfig.instance.macos?) require 'timeout_interrupt' else require 'timeout' diff --git a/spec/mongo/index/view_spec.rb b/spec/mongo/index/view_spec.rb index 1bab5470f5..c8638bbfa7 100644 --- a/spec/mongo/index/view_spec.rb +++ b/spec/mongo/index/view_spec.rb @@ -1231,7 +1231,7 @@ end let(:models) do - view.send(:normalize_models, [ options ], authorized_primary) + view.send(:normalize_models, [ options ]) end let(:expected) do @@ -1264,7 +1264,7 @@ end let(:models) do - view.send(:normalize_models, [ extended_options ], authorized_primary) + view.send(:normalize_models, [ extended_options ]) end it 'maps the ruby options to the server options' do @@ -1280,7 +1280,7 @@ end let(:models) do - view.send(:normalize_models, [ extended_options ], authorized_primary) + view.send(:normalize_models, [ extended_options ]) end let(:extended_expected) do diff --git a/spec/mongo/tracing/open_telemetry/command_tracer_spec.rb b/spec/mongo/tracing/open_telemetry/command_tracer_spec.rb new file mode 100644 index 0000000000..768f4a2683 --- /dev/null +++ b/spec/mongo/tracing/open_telemetry/command_tracer_spec.rb @@ -0,0 +1,522 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mongo::Tracing::OpenTelemetry::CommandTracer do + let(:otel_tracer) { double('OpenTelemetry::Trace::Tracer') } + let(:parent_tracer) { double('Mongo::Tracing::OpenTelemetry::Tracer') } + let(:query_text_max_length) { 0 } + let(:command_tracer) do + described_class.new(otel_tracer, parent_tracer, query_text_max_length: query_text_max_length) + end + + let(:connection) do + double('Mongo::Server::Connection', + id: 123, + address: double('Address', host: 'localhost', port: 27_017), + transport: :tcp, + server: double('Server', + description: double('Description', server_connection_id: 456))) + end + + let(:message) do + double('Mongo::Protocol::Message', + documents: [ document ], + payload: { 'command' => document }) + end + + let(:document) do + { + 'find' => 'users', + '$db' => 'test_db', + 'lsid' => { 'id' => 'session-123' }, + 'filter' => { 'name' => 'Alice' } + } + end + + let(:operation_context) { double('Mongo::Operation::Context') } + + describe '#initialize' do + it 'sets the otel_tracer' do + expect(command_tracer.instance_variable_get(:@otel_tracer)).to eq(otel_tracer) + end + + it 'sets the parent_tracer' do + expect(command_tracer.instance_variable_get(:@parent_tracer)).to eq(parent_tracer) + end + + it 'sets the query_text_max_length' do + expect(command_tracer.instance_variable_get(:@query_text_max_length)).to eq(0) + end + + context 'with custom query_text_max_length' do + let(:query_text_max_length) { 100 } + + it 'sets the custom query_text_max_length' do + expect(command_tracer.instance_variable_get(:@query_text_max_length)).to eq(100) + end + end + end + + describe '#trace_command' do + let(:span) { double('OpenTelemetry::Trace::Span', finish: nil, set_attribute: nil) } + let(:context) { double('OpenTelemetry::Context') } + let(:result) { double('Result', has_cursor_id?: false, successful?: true) } + + before do + allow(otel_tracer).to receive(:start_span).and_return(span) + allow(OpenTelemetry::Trace).to receive(:with_span).and_yield(span, context) + end + + it 'starts a span with the command name' do + expect(otel_tracer).to receive(:start_span).with( + 'find', + hash_including(kind: :client) + ) + command_tracer.trace_command(message, operation_context, connection) { result } + end + + it 'yields the block' do + yielded = false + command_tracer.trace_command(message, operation_context, connection) do + yielded = true + result + end + expect(yielded).to be true + end + + it 'returns the block result' do + return_value = command_tracer.trace_command(message, operation_context, connection) { result } + expect(return_value).to eq(result) + end + + it 'finishes the span' do + expect(span).to receive(:finish) + command_tracer.trace_command(message, operation_context, connection) { result } + end + + context 'when result has cursor_id' do + let(:result) do + double('Result', has_cursor_id?: true, cursor_id: 789, successful?: true) + end + + it 'sets the cursor_id attribute' do + expect(span).to receive(:set_attribute).with('db.mongodb.cursor_id', 789) + command_tracer.trace_command(message, operation_context, connection) { result } + end + end + + context 'when result has zero cursor_id' do + let(:result) do + double('Result', has_cursor_id?: true, cursor_id: 0, successful?: true) + end + + it 'does not set the cursor_id attribute' do + expect(span).not_to receive(:set_attribute).with('db.mongodb.cursor_id', anything) + command_tracer.trace_command(message, operation_context, connection) { result } + end + end + + context 'when result is not successful' do + let(:result) do + double('Result', + has_cursor_id?: false, + successful?: false, + error: double('Error', code: 13)) + end + + it 'sets the error status code' do + expect(span).to receive(:set_attribute).with('db.response.status_code', '13') + command_tracer.trace_command(message, operation_context, connection) { result } + end + end + + context 'when an OperationFailure exception is raised' do + let(:error) { Mongo::Error::OperationFailure.new('error', nil, code: 42) } + + before do + allow(span).to receive(:record_exception) + allow(span).to receive(:status=) + end + + it 'sets the error status code attribute' do + expect(span).to receive(:set_attribute).with('db.response.status_code', '42') + expect do + command_tracer.trace_command(message, operation_context, connection) { raise error } + end.to raise_error(error) + end + + it 'records the exception' do + expect(span).to receive(:record_exception).with(error) + expect do + command_tracer.trace_command(message, operation_context, connection) { raise error } + end.to raise_error(error) + end + + it 'sets the error status' do + expect(span).to receive(:status=) + expect do + command_tracer.trace_command(message, operation_context, connection) { raise error } + end.to raise_error(error) + end + + it 'finishes the span' do + expect(span).to receive(:finish) + expect do + command_tracer.trace_command(message, operation_context, connection) { raise error } + end.to raise_error(error) + end + end + + context 'when a generic exception is raised' do + let(:error) { StandardError.new('generic error') } + + before do + allow(span).to receive(:record_exception) + allow(span).to receive(:status=) + end + + it 'does not set status code attribute' do + expect(span).not_to receive(:set_attribute).with('db.response.status_code', anything) + expect do + command_tracer.trace_command(message, operation_context, connection) { raise error } + end.to raise_error(error) + end + + it 'records the exception' do + expect(span).to receive(:record_exception).with(error) + expect do + command_tracer.trace_command(message, operation_context, connection) { raise error } + end.to raise_error(error) + end + + it 'sets the error status' do + expect(span).to receive(:status=) + expect do + command_tracer.trace_command(message, operation_context, connection) { raise error } + end.to raise_error(error) + end + + it 'finishes the span' do + expect(span).to receive(:finish) + expect do + command_tracer.trace_command(message, operation_context, connection) { raise error } + end.to raise_error(error) + end + end + end + + describe '#span_attributes' do + subject { command_tracer.send(:span_attributes, message, connection) } + + it 'includes db.system' do + expect(subject['db.system']).to eq('mongodb') + end + + it 'includes db.namespace' do + expect(subject['db.namespace']).to eq('test_db') + end + + it 'includes db.collection.name' do + expect(subject['db.collection.name']).to eq('users') + end + + it 'includes db.command.name' do + expect(subject['db.command.name']).to eq('find') + end + + it 'includes db.query.summary' do + expect(subject['db.query.summary']).to eq('find test_db.users') + end + + it 'includes server.port' do + expect(subject['server.port']).to eq(27_017) + end + + it 'includes server.address' do + expect(subject['server.address']).to eq('localhost') + end + + it 'includes network.transport' do + expect(subject['network.transport']).to eq('tcp') + end + + it 'includes db.mongodb.server_connection_id' do + expect(subject['db.mongodb.server_connection_id']).to eq(456) + end + + it 'includes db.mongodb.driver_connection_id' do + expect(subject['db.mongodb.driver_connection_id']).to eq(123) + end + + it 'includes db.mongodb.lsid' do + expect(subject['db.mongodb.lsid']).to eq('session-123') + end + + it 'does not include nil values' do + expect(subject).not_to have_key('db.mongodb.cursor_id') + expect(subject).not_to have_key('db.mongodb.txn_number') + expect(subject).not_to have_key('db.query.text') + end + + context 'with getMore command' do + let(:document) do + { + 'getMore' => double('BSON::Int64', value: 999), + 'collection' => 'users', + '$db' => 'test_db' + } + end + + it 'includes db.mongodb.cursor_id' do + expect(subject['db.mongodb.cursor_id']).to eq(999) + end + end + + context 'with transaction number' do + let(:document) do + { + 'find' => 'users', + '$db' => 'test_db', + 'txnNumber' => double('BSON::Int64', value: 42) + } + end + + it 'includes db.mongodb.txn_number' do + expect(subject['db.mongodb.txn_number']).to eq(42) + end + end + + context 'with query text enabled' do + let(:query_text_max_length) { 1000 } + + it 'includes db.query.text' do + expect(subject['db.query.text']).to be_a(String) + expect(subject['db.query.text']).to include('find') + end + end + end + + describe '#collection_name' do + subject { command_tracer.send(:collection_name, message) } + + context 'with find command' do + let(:document) { { 'find' => 'users' } } + + it 'returns the collection name' do + expect(subject).to eq('users') + end + end + + context 'with getMore command' do + let(:document) { { 'getMore' => 123, 'collection' => 'users' } } + + it 'returns the collection name' do + expect(subject).to eq('users') + end + end + + context 'with listCollections command' do + let(:document) { { 'listCollections' => 1 } } + + it 'returns nil' do + expect(subject).to be_nil + end + end + + context 'with listDatabases command' do + let(:document) { { 'listDatabases' => 1 } } + + it 'returns nil' do + expect(subject).to be_nil + end + end + + context 'with commitTransaction command' do + let(:document) { { 'commitTransaction' => 1 } } + + it 'returns nil' do + expect(subject).to be_nil + end + end + + context 'with abortTransaction command' do + let(:document) { { 'abortTransaction' => 1 } } + + it 'returns nil' do + expect(subject).to be_nil + end + end + + context 'with admin command with numeric value' do + let(:document) { { 'serverStatus' => 1 } } + + it 'returns nil' do + expect(subject).to be_nil + end + end + end + + describe '#command_name' do + subject { command_tracer.send(:command_name, message) } + + let(:document) { { 'find' => 'users' } } + + it 'returns the command name' do + expect(subject).to eq('find') + end + end + + describe '#database' do + subject { command_tracer.send(:database, message) } + + let(:document) { { 'find' => 'users', '$db' => 'test_db' } } + + it 'returns the database name' do + expect(subject).to eq('test_db') + end + end + + describe '#query_summary' do + subject { command_tracer.send(:query_summary, message) } + + context 'with collection name' do + let(:document) { { 'find' => 'users', '$db' => 'test_db' } } + + it 'includes collection name' do + expect(subject).to eq('find test_db.users') + end + end + + context 'without collection name' do + let(:document) { { 'listCollections' => 1, '$db' => 'test_db' } } + + it 'does not include collection name' do + expect(subject).to eq('listCollections test_db') + end + end + end + + describe '#cursor_id' do + subject { command_tracer.send(:cursor_id, message) } + + context 'with getMore command' do + let(:document) { { 'getMore' => double('BSON::Int64', value: 999) } } + + it 'returns the cursor ID' do + expect(subject).to eq(999) + end + end + + context 'with find command' do + let(:document) { { 'find' => 'users' } } + + it 'returns nil' do + expect(subject).to be_nil + end + end + end + + describe '#lsid' do + subject { command_tracer.send(:lsid, message) } + + context 'with lsid present' do + let(:document) { { 'find' => 'users', 'lsid' => { 'id' => 'session-123' } } } + + it 'returns the session ID' do + expect(subject).to eq('session-123') + end + end + + context 'without lsid' do + let(:document) { { 'find' => 'users' } } + + it 'returns nil' do + expect(subject).to be_nil + end + end + end + + describe '#txn_number' do + subject { command_tracer.send(:txn_number, message) } + + context 'with txnNumber present' do + let(:document) { { 'find' => 'users', 'txnNumber' => double('BSON::Int64', value: 42) } } + + it 'returns the transaction number' do + expect(subject).to eq(42) + end + end + + context 'without txnNumber' do + let(:document) { { 'find' => 'users' } } + + it 'returns nil' do + expect(subject).to be_nil + end + end + end + + describe '#query_text' do + subject { command_tracer.send(:query_text, message) } + + context 'when query text is disabled' do + let(:query_text_max_length) { 0 } + + it 'returns nil' do + expect(subject).to be_nil + end + end + + context 'when query text is enabled' do + let(:query_text_max_length) { 1000 } + let(:document) do + { + 'find' => 'users', + '$db' => 'test_db', + 'lsid' => { 'id' => 'session-123' }, + 'filter' => { 'name' => 'Alice' } + } + end + + it 'returns JSON string without excluded keys' do + json = subject + expect(json).to be_a(String) + parsed = JSON.parse(json) + expect(parsed).to have_key('find') + expect(parsed).to have_key('filter') + expect(parsed).not_to have_key('lsid') + expect(parsed).not_to have_key('$db') + end + + context 'when query text exceeds max length' do + let(:query_text_max_length) { 10 } + + it 'truncates with ellipsis' do + expect(subject).to end_with('...') + expect(subject.length).to eq(13) # 10 chars + '...' + end + end + end + end + + describe '#query_text?' do + subject { command_tracer.send(:query_text?) } + + context 'when query_text_max_length is 0' do + let(:query_text_max_length) { 0 } + + it 'returns false' do + expect(subject).to be false + end + end + + context 'when query_text_max_length is positive' do + let(:query_text_max_length) { 100 } + + it 'returns true' do + expect(subject).to be true + end + end + end +end diff --git a/spec/mongo/tracing/open_telemetry/operation_tracer_spec.rb b/spec/mongo/tracing/open_telemetry/operation_tracer_spec.rb new file mode 100644 index 0000000000..5f5d4518e8 --- /dev/null +++ b/spec/mongo/tracing/open_telemetry/operation_tracer_spec.rb @@ -0,0 +1,651 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mongo::Tracing::OpenTelemetry::OperationTracer do + # rubocop:disable RSpec/VerifiedDoubles + # OpenTelemetry classes may not be loaded, so we use regular doubles + let(:otel_tracer) { double('OpenTelemetry::Trace::Tracer') } + let(:parent_tracer) do + instance_double( + Mongo::Tracing::OpenTelemetry::Tracer, + cursor_context_map: cursor_context_map, + parent_context_for: parent_context, + transaction_context_map: transaction_context_map, + transaction_map_key: transaction_key + ) + end + let(:cursor_context_map) { {} } + let(:transaction_context_map) { {} } + let(:parent_context) { nil } + let(:transaction_key) { nil } + let(:operation_tracer) { described_class.new(otel_tracer, parent_tracer) } + + let(:operation_context) { instance_double(Mongo::Operation::Context, session: session) } + let(:session) { instance_double(Mongo::Session) } + + describe '#initialize' do + it 'sets the otel_tracer' do + expect(operation_tracer.instance_variable_get(:@otel_tracer)).to eq(otel_tracer) + end + + it 'sets the parent_tracer' do + expect(operation_tracer.instance_variable_get(:@parent_tracer)).to eq(parent_tracer) + end + end + + describe '#trace_operation' do + let(:span) do + double('OpenTelemetry::Trace::Span').tap do |s| + allow(s).to receive(:finish) + allow(s).to receive(:set_attribute) + allow(s).to receive(:record_exception) + allow(s).to receive(:status=) + end + end + let(:context) { double('OpenTelemetry::Context') } + let(:result) { double('Result', is_a?: false) } + # rubocop:enable RSpec/VerifiedDoubles + let(:operation_class) { class_double(Mongo::Operation::Find, name: 'Mongo::Operation::Find') } + let(:operation) do + instance_double( + Mongo::Operation::Find, + db_name: 'test_db', + coll_name: 'test_collection', + cursor_id: nil, + class: operation_class, + respond_to?: true + ) + end + + before do + allow(otel_tracer).to receive(:start_span).and_return(span) + allow(OpenTelemetry::Trace).to receive(:with_span).and_yield(span, context) + allow(parent_tracer).to receive(:parent_context_for).and_return(nil) + end + + it 'starts a span with the operation name' do + expect(otel_tracer).to receive(:start_span).with( + 'find test_db.test_collection', + hash_including(kind: :client, with_parent: nil) + ) + operation_tracer.trace_operation(operation, operation_context) { result } + end + + it 'yields the block' do + yielded = false + operation_tracer.trace_operation(operation, operation_context) do + yielded = true + result + end + expect(yielded).to be true + end + + it 'returns the block result' do + return_value = operation_tracer.trace_operation(operation, operation_context) { result } + expect(return_value).to eq(result) + end + + it 'finishes the span' do + expect(span).to receive(:finish) + operation_tracer.trace_operation(operation, operation_context) { result } + end + + context 'with custom operation name' do + it 'uses the provided op_name' do + expect(otel_tracer).to receive(:start_span).with( + 'custom_op test_db.test_collection', + hash_including(kind: :client) + ) + operation_tracer.trace_operation(operation, operation_context, op_name: 'custom_op') { result } + end + end + + context 'with parent context' do + # rubocop:disable RSpec/VerifiedDoubles + let(:parent_context) { double('OpenTelemetry::Context') } + # rubocop:enable RSpec/VerifiedDoubles + + before do + allow(parent_tracer).to receive(:parent_context_for).and_return(parent_context) + end + + it 'uses the parent context' do + expect(otel_tracer).to receive(:start_span).with( + anything, + hash_including(with_parent: parent_context) + ) + operation_tracer.trace_operation(operation, operation_context) { result } + end + end + + context 'when result is a Cursor with new cursor_id' do + let(:cursor) do + instance_double( + Mongo::Cursor, + is_a?: true, + id: 12_345 + ) + end + + before do + allow(cursor).to receive(:is_a?).with(Mongo::Cursor).and_return(true) + end + + it 'stores cursor context in map' do + operation_tracer.trace_operation(operation, operation_context) { cursor } + expect(cursor_context_map[12_345]).to eq(context) + end + + it 'sets the cursor_id attribute on span' do + expect(span).to receive(:set_attribute).with('db.mongodb.cursor_id', 12_345) + operation_tracer.trace_operation(operation, operation_context) { cursor } + end + end + + context 'when result is a Cursor with closed cursor (id = 0)' do + let(:cursor_id) { 999 } + let(:cursor) do + instance_double( + Mongo::Cursor, + is_a?: true, + id: 0 + ) + end + let(:operation_class) { class_double(Mongo::Operation::GetMore, name: 'Mongo::Operation::GetMore') } + let(:operation) do + instance_double( + Mongo::Operation::GetMore, + db_name: 'test_db', + coll_name: 'test_collection', + cursor_id: cursor_id, + class: operation_class, + respond_to?: true + ) + end + + before do + allow(cursor).to receive(:is_a?).with(Mongo::Cursor).and_return(true) + # rubocop:disable RSpec/VerifiedDoubles + cursor_context_map[cursor_id] = double('OpenTelemetry::Context') + # rubocop:enable RSpec/VerifiedDoubles + end + + it 'removes cursor from context map' do + operation_tracer.trace_operation(operation, operation_context) { cursor } + expect(cursor_context_map).not_to have_key(cursor_id) + end + + it 'does not set cursor_id attribute' do + expect(span).not_to receive(:set_attribute).with('db.mongodb.cursor_id', anything) + operation_tracer.trace_operation(operation, operation_context) { cursor } + end + end + + context 'when an exception is raised' do + let(:error) { StandardError.new('test error') } + + it 'records the exception' do + expect(span).to receive(:record_exception).with(error) + expect do + operation_tracer.trace_operation(operation, operation_context) { raise error } + end.to raise_error(error) + end + + it 'sets the error status' do + expect(span).to receive(:status=) do |status| + expect(status).to be_a(OpenTelemetry::Trace::Status) + expect(status.description).to match(/Unhandled exception of type: StandardError/) + end + expect do + operation_tracer.trace_operation(operation, operation_context) { raise error } + end.to raise_error(error) + end + + it 'finishes the span' do + expect(span).to receive(:finish) + expect do + operation_tracer.trace_operation(operation, operation_context) { raise error } + end.to raise_error(error) + end + + it 'reraises the exception' do + expect do + operation_tracer.trace_operation(operation, operation_context) { raise error } + end.to raise_error(error) + end + end + end + + describe '#span_attributes' do + subject(:attributes) { operation_tracer.send(:span_attributes, operation, op_name) } + + let(:op_name) { nil } + let(:operation_class) { class_double(Mongo::Operation::Find, name: 'Mongo::Operation::Find') } + let(:operation) do + instance_double( + Mongo::Operation::Find, + db_name: 'test_db', + coll_name: 'users', + cursor_id: nil, + class: operation_class, + respond_to?: true + ) + end + + it 'includes db.system' do + expect(attributes['db.system']).to eq('mongodb') + end + + it 'includes db.namespace' do + expect(attributes['db.namespace']).to eq('test_db') + end + + it 'includes db.collection.name' do + expect(attributes['db.collection.name']).to eq('users') + end + + it 'includes db.operation.name' do + expect(attributes['db.operation.name']).to eq('find') + end + + it 'includes db.operation.summary' do + expect(attributes['db.operation.summary']).to eq('find test_db.users') + end + + it 'does not include nil values' do + expect(attributes).not_to have_key('db.mongodb.cursor_id') + end + + context 'with cursor_id' do + let(:operation_class) { class_double(Mongo::Operation::GetMore, name: 'Mongo::Operation::GetMore') } + let(:operation) do + instance_double( + Mongo::Operation::GetMore, + db_name: 'test_db', + coll_name: 'users', + cursor_id: 12_345, + class: operation_class, + respond_to?: true + ) + end + + it 'includes db.mongodb.cursor_id' do + expect(attributes['db.mongodb.cursor_id']).to eq(12_345) + end + end + + context 'with custom op_name' do + let(:op_name) { 'custom_operation' } + + it 'uses the custom op_name' do + expect(attributes['db.operation.name']).to eq('custom_operation') + end + end + end + + describe '#operation_name' do + subject(:op_name_result) { operation_tracer.send(:operation_name, operation, op_name) } + + let(:op_name) { nil } + let(:operation_class) { class_double(Mongo::Operation::Find, name: 'Mongo::Operation::Find') } + let(:operation) do + instance_double(Mongo::Operation::Find, class: operation_class) + end + + it 'returns the operation class name in lowercase' do + expect(op_name_result).to eq('find') + end + + context 'with custom op_name' do + let(:op_name) { 'CustomOperation' } + + it 'returns the custom op_name' do + expect(op_name_result).to eq('CustomOperation') + end + end + end + + describe '#collection_name' do + subject(:coll_name) { operation_tracer.send(:collection_name, operation) } + + context 'when operation responds to coll_name and has a value' do + let(:operation) do + instance_double(Mongo::Operation::Find, coll_name: 'test_collection', respond_to?: true) + end + + it 'returns the coll_name' do + expect(coll_name).to eq('test_collection') + end + end + + context 'when operation does not respond to coll_name' do + context 'with Aggregate operation' do + let(:operation) do + instance_double( + Mongo::Operation::Aggregate, + respond_to?: false, + spec: { selector: { aggregate: 'agg_collection' } } + ) + end + + before do + allow(Mongo::Operation::Aggregate).to receive(:===).with(operation).and_return(true) + allow(Mongo::Operation::Count).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Create).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Distinct).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Drop).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::WriteCommand).to receive(:===).with(operation).and_return(false) + end + + it 'returns the aggregate collection name' do + expect(coll_name).to eq('agg_collection') + end + end + + context 'with Count operation' do + let(:operation) do + instance_double( + Mongo::Operation::Count, + respond_to?: false, + spec: { selector: { count: 'count_collection' } } + ) + end + + before do + allow(Mongo::Operation::Aggregate).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Count).to receive(:===).with(operation).and_return(true) + allow(Mongo::Operation::Create).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Distinct).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Drop).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::WriteCommand).to receive(:===).with(operation).and_return(false) + end + + it 'returns the count collection name' do + expect(coll_name).to eq('count_collection') + end + end + + context 'with Create operation' do + let(:operation) do + instance_double( + Mongo::Operation::Create, + respond_to?: false, + spec: { selector: { create: 'new_collection' } } + ) + end + + before do + allow(Mongo::Operation::Aggregate).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Count).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Create).to receive(:===).with(operation).and_return(true) + allow(Mongo::Operation::Distinct).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Drop).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::WriteCommand).to receive(:===).with(operation).and_return(false) + end + + it 'returns the create collection name' do + expect(coll_name).to eq('new_collection') + end + end + + context 'with Distinct operation' do + let(:operation) do + instance_double( + Mongo::Operation::Distinct, + respond_to?: false, + spec: { selector: { distinct: 'distinct_collection' } } + ) + end + + before do + allow(Mongo::Operation::Aggregate).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Count).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Create).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Distinct).to receive(:===).with(operation).and_return(true) + allow(Mongo::Operation::Drop).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::WriteCommand).to receive(:===).with(operation).and_return(false) + end + + it 'returns the distinct collection name' do + expect(coll_name).to eq('distinct_collection') + end + end + + context 'with Drop operation' do + let(:operation) do + instance_double( + Mongo::Operation::Drop, + respond_to?: false, + spec: { selector: { drop: 'dropped_collection' } } + ) + end + + before do + allow(Mongo::Operation::Aggregate).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Count).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Create).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Distinct).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Drop).to receive(:===).with(operation).and_return(true) + allow(Mongo::Operation::WriteCommand).to receive(:===).with(operation).and_return(false) + end + + it 'returns the drop collection name' do + expect(coll_name).to eq('dropped_collection') + end + end + + context 'with WriteCommand operation' do + let(:operation) do + instance_double( + Mongo::Operation::WriteCommand, + respond_to?: false, + spec: { selector: { insert: 'write_collection' } } + ) + end + + before do + allow(Mongo::Operation::Aggregate).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Count).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Create).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Distinct).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::Drop).to receive(:===).with(operation).and_return(false) + allow(Mongo::Operation::WriteCommand).to receive(:===).with(operation).and_return(true) + end + + it 'returns the first value from selector' do + expect(coll_name).to eq('write_collection') + end + end + + context 'with unknown operation type' do + let(:operation) do + instance_double(Mongo::Operation::Find, respond_to?: false) + end + + before do + allow(operation).to receive(:is_a?).and_return(false) + end + + it 'returns nil' do + expect(coll_name).to be_nil + end + end + end + + context 'when coll_name is nil' do + let(:operation) do + instance_double( + Mongo::Operation::Find, + coll_name: nil, + respond_to?: true, + spec: { selector: { listCollections: 1 } } + ) + end + + before do + stub_const('Mongo::Operation::Aggregate', Class.new) + allow(operation).to receive(:is_a?).with(Mongo::Operation::Aggregate).and_return(false) + allow(operation).to receive(:is_a?).and_return(false) + end + + it 'returns nil' do + expect(coll_name).to be_nil + end + end + end + + describe '#operation_span_name' do + subject(:span_name) { operation_tracer.send(:operation_span_name, operation, op_name) } + + let(:op_name) { nil } + + context 'with collection name' do + let(:operation_class) { class_double(Mongo::Operation::Find, name: 'Mongo::Operation::Find') } + let(:operation) do + instance_double( + Mongo::Operation::Find, + db_name: 'test_db', + coll_name: 'users', + class: operation_class, + respond_to?: true + ) + end + + it 'includes collection name in format' do + expect(span_name).to eq('find test_db.users') + end + end + + context 'without collection name' do + let(:operation_class) do + class_double(Mongo::Operation::ListCollections, name: 'Mongo::Operation::ListCollections') + end + let(:operation) do + instance_double( + Mongo::Operation::ListCollections, + db_name: 'test_db', + coll_name: nil, + class: operation_class, + respond_to?: true + ) + end + + before do + allow(operation).to receive(:is_a?).and_return(false) + end + + it 'excludes collection name from format' do + expect(span_name).to eq('listcollections test_db') + end + end + + context 'with empty collection name' do + let(:operation_class) { class_double(Mongo::Operation::Command, name: 'Mongo::Operation::Command') } + let(:operation) do + instance_double( + Mongo::Operation::Command, + db_name: 'test_db', + coll_name: '', + class: operation_class, + respond_to?: true + ) + end + + it 'excludes collection name from format' do + expect(span_name).to eq('command test_db') + end + end + end + + describe '#process_cursor_context' do + subject(:process_result) do + operation_tracer.send(:process_cursor_context, result, cursor_id, context, span) + end + + # rubocop:disable RSpec/VerifiedDoubles + let(:context) { double('OpenTelemetry::Context') } + let(:span) do + double('OpenTelemetry::Trace::Span').tap do |s| + allow(s).to receive(:set_attribute) + end + end + let(:cursor_id) { nil } + + context 'when result is not a Cursor' do + let(:result) { double('Result', is_a?: false) } + # rubocop:enable RSpec/VerifiedDoubles + + it 'does not modify cursor_context_map' do + expect { process_result }.not_to(change { cursor_context_map }) + end + end + + context 'when result is a Cursor with zero id' do + let(:cursor_id) { 123 } + let(:result) do + instance_double(Mongo::Cursor, is_a?: true, id: 0) + end + + before do + allow(result).to receive(:is_a?).with(Mongo::Cursor).and_return(true) + # rubocop:disable RSpec/VerifiedDoubles + cursor_context_map[cursor_id] = double('OpenTelemetry::Context') + # rubocop:enable RSpec/VerifiedDoubles + end + + it 'removes the cursor from context map' do + process_result + expect(cursor_context_map).not_to have_key(cursor_id) + end + + it 'does not set cursor_id attribute on span' do + expect(span).not_to receive(:set_attribute) + process_result + end + end + + context 'when result is a Cursor with new id' do + let(:result) do + instance_double(Mongo::Cursor, is_a?: true, id: 456) + end + + before do + allow(result).to receive(:is_a?).with(Mongo::Cursor).and_return(true) + end + + it 'stores the context in cursor_context_map' do + process_result + expect(cursor_context_map[456]).to eq(context) + end + + it 'sets the cursor_id attribute on span' do + expect(span).to receive(:set_attribute).with('db.mongodb.cursor_id', 456) + process_result + end + end + + context 'when result is a Cursor with existing id' do + let(:cursor_id) { 789 } + let(:result) do + instance_double(Mongo::Cursor, is_a?: true, id: 789) + end + + before do + allow(result).to receive(:is_a?).with(Mongo::Cursor).and_return(true) + # rubocop:disable RSpec/VerifiedDoubles + cursor_context_map[cursor_id] = double('OpenTelemetry::Context') + # rubocop:enable RSpec/VerifiedDoubles + end + + it 'does not update cursor_context_map' do + expect { process_result }.not_to(change(cursor_context_map, :keys)) + end + + it 'does not set cursor_id attribute on span' do + expect(span).not_to receive(:set_attribute) + process_result + end + end + end +end diff --git a/spec/runners/unified.rb b/spec/runners/unified.rb index 042b1c3947..a58a469d9c 100644 --- a/spec/runners/unified.rb +++ b/spec/runners/unified.rb @@ -99,6 +99,7 @@ def define_unified_spec_tests(base_path, paths, expect_failure: false) test.run test.assert_outcome test.assert_events + test.assert_tracing_messages test.cleanup end end diff --git a/spec/runners/unified/assertions.rb b/spec/runners/unified/assertions.rb index 69cb2282ad..64c6af3d27 100644 --- a/spec/runners/unified/assertions.rb +++ b/spec/runners/unified/assertions.rb @@ -140,9 +140,14 @@ def assert_documents_match(actual, expected) end end - def assert_document_matches(actual, expected, msg) - unless actual == expected - raise Error::ResultMismatch, "#{msg} does not match" + def assert_document_matches(actual, expected, msg, as_root: false) + if !as_root && actual.keys.to_set != expected.keys.to_set + raise Error::ResultMismatch, "#{msg} keys do not match: expected #{expected.keys}, actual #{actual.keys}" + end + expected.each do |key, expected_value| + raise Error::ResultMismatch, "#{msg} has no key #{key}: #{actual}" unless actual.key?(key) + actual_value = actual[key] + assert_value_matches(actual_value, expected_value, "#{msg} key #{key}") end end @@ -303,11 +308,11 @@ def get_actual_value(actual, key) end end - def assert_type(object, type) + def assert_type(object, type, msg = nil) ok = [*type].reduce(false) { |acc, x| acc || type_matches?(object, x) } unless ok - raise Error::ResultMismatch, "Object #{object} is not of type #{type}" + raise Error::ResultMismatch, (msg || '') + " Object '#{object}' is not of type #{type}" end end @@ -373,7 +378,7 @@ def assert_value_matches(actual, expected, msg) # raise Error::ResultMismatch, "Session does not match: wanted #{expected_session}, have #{actual_v}" #end when '$$type' - assert_type(actual, expected_v) + assert_type(actual, expected_v, msg) when '$$matchesEntity' result = entities.get(:result, expected_v) unless actual == result @@ -383,6 +388,14 @@ def assert_value_matches(actual, expected, msg) if actual.nil? || actual >= expected_v raise Error::ResultMismatch, "Actual value #{actual} should be less than #{expected_v}" end + when '$$matchAsDocument' + actual_v = BSON::ExtJSON.parse(actual) + match_as_root = false + if expected_v.keys.first == '$$matchAsRoot' + expected_v = expected_v.values.first + match_as_root = true + end + assert_document_matches(actual_v, expected_v, msg, as_root: match_as_root) else raise NotImplementedError, "Unknown operator #{operator}" end @@ -392,5 +405,53 @@ def assert_value_matches(actual, expected, msg) end end end + + def assert_tracing_messages + return unless @expected_tracing_messages + + @expected_tracing_messages.each do |spec| + spec = UsingHash[spec] + client_id = spec.use!('client') + client = entities.get(:client, client_id) + tracer = @tracers.fetch(client) + expected_spans = spec.use!('spans') + ignore_extra_spans = if ignore = spec.use('ignoreExtraSpans') + # Ruby treats 0 as truthy, whereas the spec tests use it as falsy. + ignore == 0 ? false : ignore + else + false + end + actual_spans = tracer.span_hierarchy + if (!ignore_extra_spans && actual_spans.length != expected_spans.length) || + (ignore_extra_spans && actual_spans.length < expected_spans.length) + raise Error::ResultMismatch, "Span count mismatch: expected #{expected_spans.length}, actual #{actual_spans.length}\nExpected: #{expected_spans}\nActual: #{actual_spans}" + end + if ignore_extra_spans + expected_spans.each do |expected| + actual = actual_spans.find { |s| s.name == expected['name'] } + raise Error::ResultMismatch, "Could not find span with name #{expected['name']}" if actual.nil? + assert_span_matches(actual, expected) + end + else + expected_spans.each_with_index do |expected, i| + assert_span_matches(actual_spans[i], expected) + end + end + end + end + + def assert_span_matches(actual, expected) + assert_eq(actual.name, expected.use!('name'), 'Span name does not match') + expected_attributes = UsingHash[expected.use!('attributes')] + expected_attributes.each do |key, value| + actual_value = actual.attributes[key] + assert_value_matches(actual_value, value, "Span attribute #{key}") + end + + expected_nested_spans = expected.use('nested') || [] + expected_nested_spans.each_with_index do |nested_expected, i| + assert_span_matches(actual.nested[i], nested_expected) + end + end end end diff --git a/spec/runners/unified/test.rb b/spec/runners/unified/test.rb index 32b0ba0a82..8c40a37579 100644 --- a/spec/runners/unified/test.rb +++ b/spec/runners/unified/test.rb @@ -37,6 +37,8 @@ def initialize(spec, **opts) @description = @test_spec.use('description') @outcome = @test_spec.use('outcome') @expected_events = @test_spec.use('expectEvents') + @expected_tracing_messages = @test_spec.use('expectTracingMessages') + @expected_spans = @test_spec.use('expectSpans') @skip_reason = @test_spec.use('skipReason') if req = @test_spec.use('runOnRequirements') @reqs = req.map { |r| Mongo::CRUD::Requirement.new(r) } @@ -54,6 +56,7 @@ def initialize(spec, **opts) end @test_spec.freeze @subscribers = {} + @tracers = {} @observe_sensitive = {} @options = opts end @@ -195,9 +198,24 @@ def generate_entities(es) end end + observe_tracing_messages = spec.use('observeTracingMessages') + tracer = ::Tracing::Tracer.new + if observe_tracing_messages + opts[:tracing] = { + enabled: true, + tracer: tracer, + } + if observe_tracing_messages['enableCommandPayload'] + # Set the maximum length of the query text to reasonably high + # value so that we can capture the full query text + opts[:tracing][:query_text_max_length] = 4096 + end + end + create_client(**opts).tap do |client| @observe_sensitive[id] = spec.use('observeSensitiveCommands') @subscribers[client] ||= subscriber + @tracers[client] ||= tracer end when 'database' client = entities.get(:client, spec.use!('client')) diff --git a/spec/spec_tests/data/open_telemetry/README.md b/spec/spec_tests/data/open_telemetry/README.md new file mode 100644 index 0000000000..99bcc8d293 --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/README.md @@ -0,0 +1,60 @@ +# OpenTelemetry Tests + +______________________________________________________________________ + +## Testing + +### Automated Tests + +The YAML and JSON files in this directory are platform-independent tests meant to exercise a driver's implementation of +the OpenTelemetry specification. These tests utilize the +[Unified Test Format](../../unified-test-format/unified-test-format.md). + +For each test, create a MongoClient, configure it to enable tracing. + +```yaml +createEntities: + - client: + id: client0 + observeTracingMessages: + enableCommandPayload: true +``` + +These tests require the ability to collect tracing [spans](../open-telemetry.md) data in a structured form as described +in the +[Unified Test Format specification.expectTracingMessages](../../unified-test-format/unified-test-format.md#expectTracingMessages). +For example the Java driver uses [Micrometer](https://jira.mongodb.org/browse/JAVA-5732) to collect tracing spans. + +```yaml +expectTracingMessages: + client: client0 + ignoreExtraSpans: false + spans: + ... +``` + +### Prose Tests + +*Test 1: Tracing Enable/Disable via Environment Variable* + +1. Set the environment variable `OTEL_#{LANG}_INSTRUMENTATION_MONGODB_ENABLED` to `false`. +2. Create a `MongoClient` without explicitly enabling tracing. +3. Perform a database operation (e.g., `find()` on a test collection). +4. Assert that no OpenTelemetry tracing spans are emitted for the operation. +5. Set the environment variable `OTEL_#{LANG}_INSTRUMENTATION_MONGODB_ENABLED` to `true`. +6. Create a new `MongoClient` without explicitly enabling tracing. +7. Perform the same database operation. +8. Assert that OpenTelemetry tracing spans are emitted for the operation. + +*Test 2: Command Payload Emission via Environment Variable* + +1. Set the environment variable `OTEL_#{LANG}_INSTRUMENTATION_MONGODB_ENABLED` to `true`. +2. Set the environment variable `OTEL_#{LANG}_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH` to a positive integer + (e.g., 1024). +3. Create a `MongoClient` without explicitly enabling command payload emission. +4. Perform a database operation (e.g., `find()`). +5. Assert that the emitted tracing span includes the `db.query.text` attribute. +6. Unset the environment variable `OTEL_#{LANG}_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH`. +7. Create a new `MongoClient`. +8. Perform the same database operation. +9. Assert that the emitted tracing span does not include the `db.query.text` attribute. diff --git a/spec/spec_tests/data/open_telemetry/operation/aggregate.yml b/spec/spec_tests/data/open_telemetry/operation/aggregate.yml new file mode 100644 index 0000000000..630000f441 --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/aggregate.yml @@ -0,0 +1,62 @@ +description: operation aggregate +schemaVersion: '1.27' +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - database: + id: &database0 database0 + client: *client0 + databaseName: operation-aggregate + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: &collectionName0 test + +tests: + - description: aggregation + operations: + - name: aggregate + object: *collection0 + arguments: + pipeline: &pipeline0 + - $match: { _id: 1 } + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: aggregate operation-aggregate.test + attributes: + db.system: mongodb + db.namespace: operation-aggregate + db.collection.name: test + db.operation.name: aggregate + db.operation.summary: aggregate operation-aggregate.test + + nested: + - name: aggregate + attributes: + db.system: mongodb + db.namespace: operation-aggregate + db.collection.name: *collectionName0 + db.command.name: aggregate + network.transport: tcp + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + server.address: { $$type: string } + server.port: { $$type: [int, long] } + db.query.summary: aggregate operation-aggregate.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + aggregate: test + pipeline: *pipeline0 + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] diff --git a/spec/spec_tests/data/open_telemetry/operation/atlas_search.yml b/spec/spec_tests/data/open_telemetry/operation/atlas_search.yml new file mode 100644 index 0000000000..d5029f994f --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/atlas_search.yml @@ -0,0 +1,170 @@ +description: operation atlas_search +schemaVersion: '1.27' +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - database: + id: &database0 database0 + client: *client0 + databaseName: operation-atlas-search + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: test + +runOnRequirements: + # Skip server versions without fix of SERVER-83107 to avoid error message "BSON field 'createSearchIndexes.indexes.type' is an unknown field." + # SERVER-83107 was not backported to 7.1. + - minServerVersion: "7.0.5" + maxServerVersion: "7.0.99" + topologies: [ replicaset, load-balanced, sharded ] + serverless: forbid + - minServerVersion: "7.2.0" + topologies: [ replicaset, load-balanced, sharded ] + serverless: forbid + + +tests: + - description: atlas search indexes + operations: + - name: createSearchIndex + object: *collection0 + arguments: + model: { definition: { mappings: { dynamic: true } } , type: 'search' } + expectError: + # This test always errors in a non-Atlas environment. The test functions as a unit test by asserting + # that the driver constructs and sends the correct command. + # The expected error message was changed in SERVER-83003. Check for the substring "Atlas" shared by both error messages. + isError: true + errorContains: Atlas + + - name: updateSearchIndex + object: *collection0 + arguments: + name: 'test index' + definition: {} + expectError: + # This test always errors in a non-Atlas environment. The test functions as a unit test by asserting + # that the driver constructs and sends the correct command. + # The expected error message was changed in SERVER-83003. Check for the substring "Atlas" shared by both error messages. + isError: true + errorContains: Atlas + + - name: dropSearchIndex + object: *collection0 + arguments: + name: 'test index' + expectError: + # This test always errors in a non-Atlas environment. The test functions as a unit test by asserting + # that the driver constructs and sends the correct command. + # The expected error message was changed in SERVER-83003. Check for the substring "Atlas" shared by both error messages. + isError: true + errorContains: Atlas + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: createSearchIndexes operation-atlas-search.test + attributes: + db.system: mongodb + db.namespace: operation-atlas-search + db.collection.name: test + db.operation.name: createSearchIndexes + db.operation.summary: createSearchIndexes operation-atlas-search.test + nested: + - name: createSearchIndexes + attributes: + db.system: mongodb + db.namespace: operation-atlas-search + db.command.name: createSearchIndexes + db.collection.name: test + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: true } + network.transport: tcp + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] + exception.message: { $$exists: true } + exception.type: { $$exists: true } + exception.stacktrace: { $$exists: true } + server.address: { $$type: string } + server.port: { $$type: [ long, string ] } + db.query.summary: createSearchIndexes operation-atlas-search.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + createSearchIndexes: test + indexes: [ { "type": "search", "definition": { "mappings": { "dynamic": true } } } ] + + - name: updateSearchIndex operation-atlas-search.test + attributes: + db.system: mongodb + db.namespace: operation-atlas-search + db.collection.name: test + db.operation.name: updateSearchIndex + db.operation.summary: updateSearchIndex operation-atlas-search.test + + nested: + - name: updateSearchIndex + attributes: + db.system: mongodb + db.namespace: operation-atlas-search + db.command.name: updateSearchIndex + db.collection.name: test + server.address: { $$type: string } + server.port: { $$type: [ long, string ] } + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: true } + exception.message: { $$exists: true } + exception.type: { $$exists: true } + exception.stacktrace: { $$exists: true } + network.transport: tcp + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] + db.query.summary: updateSearchIndex operation-atlas-search.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + updateSearchIndex: test + name: test index + definition: {} + + - name: dropSearchIndex operation-atlas-search.test + attributes: + db.system: mongodb + db.namespace: operation-atlas-search + db.collection.name: test + db.operation.name: dropSearchIndex + db.operation.summary: dropSearchIndex operation-atlas-search.test + nested: + - name: dropSearchIndex + attributes: + db.system: mongodb + db.namespace: operation-atlas-search + db.command.name: dropSearchIndex + db.collection.name: test + server.address: { $$type: string } + server.port: { $$type: [ long, string ] } + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: true } + exception.message: { $$exists: true } + exception.type: { $$exists: true } + exception.stacktrace: { $$exists: true } + network.transport: tcp + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] + db.query.summary: dropSearchIndex operation-atlas-search.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + dropSearchIndex: test + name: test index diff --git a/spec/spec_tests/data/open_telemetry/operation/bulk_write.yml b/spec/spec_tests/data/open_telemetry/operation/bulk_write.yml new file mode 100644 index 0000000000..6f84e50fb1 --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/bulk_write.yml @@ -0,0 +1,198 @@ +description: operation bulk_write +schemaVersion: '1.27' +runOnRequirements: + - minServerVersion: "8.0" + serverless: forbid + +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - database: + id: &database0 database0 + client: *client0 + databaseName: &databaseName0 operation-bulk-write-0 + - database: + id: &database1 database1 + client: *client0 + databaseName: &databaseName1 operation-bulk-write-1 + - collection: + id: collection0 + database: *database0 + collectionName: &collectionName0 test0 + - collection: + id: collection1 + database: *database1 + collectionName: &collectionName1 test1 + +initialData: + - collectionName: *collectionName0 + databaseName: *databaseName0 + documents: [ ] + - collectionName: *collectionName1 + databaseName: *databaseName1 + documents: [ ] + +_yamlAnchors: + namespace0: &namespace0 "operation-bulk-write-0.test0" + namespace1: &namespace1 "operation-bulk-write-1.test1" + +tests: + - description: bulkWrite + operations: + - object: *client0 + name: clientBulkWrite + arguments: + models: + - insertOne: + namespace: *namespace0 + document: { _id: 8, x: 88 } + - updateOne: + namespace: *namespace0 + filter: { _id: 1 } + update: { $inc: { x: 1 } } + - updateMany: + namespace: *namespace1 + filter: + $and: [ { _id: { $gt: 1 } }, { _id: { $lte: 3 } } ] + update: { $inc: { x: 2 } } + - replaceOne: + namespace: *namespace1 + filter: { _id: 4 } + replacement: { x: 44 } + upsert: true + - deleteOne: + namespace: *namespace0 + filter: { _id: 5 } + - deleteMany: + namespace: *namespace1 + filter: + $and: [ { _id: { $gt: 5 } }, { _id: { $lte: 7 } } ] + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: bulkWrite admin + attributes: + db.system: mongodb + db.namespace: admin + db.collection.name: { $$exists: false } + db.operation.name: bulkWrite + db.operation.summary: bulkWrite admin + nested: + - name: bulkWrite + attributes: + db.system: mongodb + db.namespace: admin + db.collection.name: { $$exists: false } + db.command.name: bulkWrite + network.transport: tcp + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + server.address: { $$type: string } + server.port: { $$type: [ int, long ] } + db.query.summary: bulkWrite admin + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + bulkWrite: 1 + errorsOnly: true + ordered: true + ops: [ + { + "insert": 0, + "document": { + "_id": 8, + "x": 88 + } + }, + { + "update": 0, + "multi": false, + "filter": { + "_id": 1 + }, + "updateMods": { + "$inc": { + "x": 1 + } + } + }, + { + "update": 1, + "multi": true, + "filter": { + "$and": [ + { + "_id": { + "$gt": 1 + } + }, + { + "_id": { + "$lte": 3 + } + } + ] + }, + "updateMods": { + "$inc": { + "x": 2 + } + } + }, + { + "update": 1, + "multi": false, + "filter": { + "_id": 4 + }, + "updateMods": { + "x": 44 + }, + "upsert": true + }, + { + "delete": 0, + "multi": false, + "filter": { + "_id": 5 + } + }, + { + "delete": 1, + "multi": true, + "filter": { + "$and": [ + { + "_id": { + "$gt": 5 + } + }, + { + "_id": { + "$lte": 7 + } + } + ] + } + } + ] + nsInfo: [ + { + "ns": *namespace0 + }, + { + "ns": *namespace1 + } + ] + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] diff --git a/spec/spec_tests/data/open_telemetry/operation/count.yml b/spec/spec_tests/data/open_telemetry/operation/count.yml new file mode 100644 index 0000000000..23c5b45880 --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/count.yml @@ -0,0 +1,63 @@ +description: operation count +schemaVersion: '1.27' +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - database: + id: database0 + client: *client0 + databaseName: &database0Name operation-count + - collection: + id: &collection0 collection0 + database: database0 + collectionName: &collection0Name test +initialData: + - collectionName: *collection0Name + databaseName: *database0Name + documents: [] +tests: + - description: estimated document count + operations: + - object: *collection0 + name: estimatedDocumentCount + arguments: { } + expectResult: 0 + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: estimatedDocumentCount operation-count.test + attributes: + db.system: mongodb + db.namespace: *database0Name + db.collection.name: *collection0Name + db.operation.name: estimatedDocumentCount + db.operation.summary: estimatedDocumentCount operation-count.test + nested: + - name: count + attributes: + db.system: mongodb + db.namespace: *database0Name + db.collection.name: *collection0Name + db.command.name: count + network.transport: tcp + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + server.address: { $$type: string } + server.port: { $$type: [int, long] } + db.query.summary: count operation-count.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + count: test + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] diff --git a/spec/spec_tests/data/open_telemetry/operation/create_collection.yml b/spec/spec_tests/data/open_telemetry/operation/create_collection.yml new file mode 100644 index 0000000000..d0dee36051 --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/create_collection.yml @@ -0,0 +1,55 @@ +description: operation create collection +schemaVersion: '1.27' +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - database: + id: &database0 database0 + client: *client0 + databaseName: &database0Name operation-create-collection +tests: + - description: create collection + operations: + - object: *database0 + name: createCollection + arguments: + collection: &collectionName newlyCreatedCollection + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: createCollection operation-create-collection.newlyCreatedCollection + attributes: + db.system: mongodb + db.namespace: *database0Name + db.collection.name: *collectionName + db.operation.name: createCollection + db.operation.summary: createCollection operation-create-collection.newlyCreatedCollection + nested: + - name: create + attributes: + db.system: mongodb + db.namespace: *database0Name + db.collection.name: *collectionName + db.command.name: create + network.transport: tcp + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + server.address: { $$type: string } + server.port: { $$type: [int, long] } + db.query.summary: create operation-create-collection.newlyCreatedCollection + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + create: newlyCreatedCollection + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] diff --git a/spec/spec_tests/data/open_telemetry/operation/create_indexes.yml b/spec/spec_tests/data/open_telemetry/operation/create_indexes.yml new file mode 100644 index 0000000000..01e23e242c --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/create_indexes.yml @@ -0,0 +1,60 @@ +description: operation create_indexes +schemaVersion: '1.27' +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - database: + id: &database0 database0 + client: *client0 + databaseName: &database0Name operation-create-indexes + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: &collection0Name test +tests: + - description: create indexes + operations: + - object: *collection0 + name: createIndex + arguments: + keys: { x: 1 } + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: createIndexes operation-create-indexes.test + attributes: + db.system: mongodb + db.namespace: *database0Name + db.collection.name: *collection0Name + db.operation.name: createIndexes + db.operation.summary: createIndexes operation-create-indexes.test + nested: + - name: createIndexes + attributes: + db.system: mongodb + db.namespace: *database0Name + db.collection.name: *collection0Name + db.command.name: createIndexes + network.transport: tcp + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + server.address: { $$type: string } + server.port: { $$type: [int, long] } + db.query.summary: createIndexes operation-create-indexes.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + createIndexes: test + indexes: [ { key: { x: 1 }, name: "x_1" } ] + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] diff --git a/spec/spec_tests/data/open_telemetry/operation/delete.yml b/spec/spec_tests/data/open_telemetry/operation/delete.yml new file mode 100644 index 0000000000..757c5a1979 --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/delete.yml @@ -0,0 +1,62 @@ +description: operation delete +schemaVersion: '1.27' +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - database: + id: &database0 database0 + client: *client0 + databaseName: &databaseName0 operation-delete + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: &collectionName0 test + +tests: + - description: delete elements + operations: + - object: *collection0 + name: deleteMany + arguments: + filter: { _id: { $gt: 1 } } + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: deleteMany operation-delete.test + attributes: + db.system: mongodb + db.namespace: *databaseName0 + db.collection.name: *collectionName0 + db.operation.name: deleteMany + db.operation.summary: deleteMany operation-delete.test + nested: + - name: delete + attributes: + db.system: mongodb + db.command.name: delete + db.namespace: operation-delete + db.collection.name: test + server.address: { $$type: string } + server.port: { $$type: [ long, string ] } + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + network.transport: tcp + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] + db.query.summary: delete operation-delete.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + delete: test + ordered: true + deletes: [ { q: { _id: { $gt: 1 } }, limit: 0 } ] diff --git a/spec/spec_tests/data/open_telemetry/operation/distinct.yml b/spec/spec_tests/data/open_telemetry/operation/distinct.yml new file mode 100644 index 0000000000..0b78cecca9 --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/distinct.yml @@ -0,0 +1,66 @@ +description: operation distinct +schemaVersion: '1.27' +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - database: + id: database0 + client: *client0 + databaseName: operation-distinct + - collection: + id: &collection0 collection0 + database: database0 + collectionName: test +initialData: + - collectionName: test + databaseName: operation-distinct + documents: [] +tests: + - description: distinct on a field + operations: + - object: *collection0 + name: distinct + arguments: + fieldName: x + filter: {} + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: distinct operation-distinct.test + attributes: + db.system: mongodb + db.namespace: operation-distinct + db.collection.name: test + db.operation.name: distinct + db.operation.summary: distinct operation-distinct.test + nested: + - name: distinct + attributes: + db.system: mongodb + db.namespace: operation-distinct + db.collection.name: test + db.command.name: distinct + network.transport: tcp + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + server.address: { $$type: string } + server.port: { $$type: [int, long] } + db.query.summary: distinct operation-distinct.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + distinct: test + key: x + query: { } + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] diff --git a/spec/spec_tests/data/open_telemetry/operation/drop_collection.yml b/spec/spec_tests/data/open_telemetry/operation/drop_collection.yml new file mode 100644 index 0000000000..7308c83b5e --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/drop_collection.yml @@ -0,0 +1,65 @@ +description: operation drop collection +schemaVersion: '1.27' + +runOnRequirements: + - minServerVersion: "7.0" + +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - database: + id: &database0 database0 + client: *client0 + databaseName: operation-drop-collection + + - collection: + id: collection0 + database: *database0 + collectionName: &collection_name test +tests: + - description: drop collection + operations: + - object: *database0 + name: dropCollection + arguments: + collection: *collection_name + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: dropCollection operation-drop-collection.test + attributes: + db.system: mongodb + db.namespace: operation-drop-collection + db.collection.name: test + db.operation.name: dropCollection + db.operation.summary: dropCollection operation-drop-collection.test + + nested: + - name: drop + attributes: + db.system: mongodb + db.namespace: operation-drop-collection + db.collection.name: test + db.command.name: drop + network.transport: tcp + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + server.address: { $$type: string } + server.port: { $$type: [int, long] } + db.query.summary: drop operation-drop-collection.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + drop: test + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] diff --git a/spec/spec_tests/data/open_telemetry/operation/drop_indexes.yml b/spec/spec_tests/data/open_telemetry/operation/drop_indexes.yml new file mode 100644 index 0000000000..519995b743 --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/drop_indexes.yml @@ -0,0 +1,78 @@ +description: operation drop indexes +schemaVersion: '1.27' +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - database: + id: &database0 database0 + client: *client0 + databaseName: operation-drop-indexes + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: test + - client: + id: &clientWithoutTracing clientWithoutTracing + useMultipleMongoses: false + - database: + id: &databaseWithoutTracing databaseWithoutTracing + client: *clientWithoutTracing + databaseName: operation-drop-indexes + - collection: + id: &collectionWithoutTracing collectionWithoutTracing + database: *databaseWithoutTracing + collectionName: test + +tests: + - description: drop indexes + operations: + - name: createIndex + object: *collectionWithoutTracing + arguments: + keys: + x: 1 + name: x_1 + + - name: dropIndexes + object: *collection0 + + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: true + spans: + - name: dropIndexes operation-drop-indexes.test + attributes: + db.system: mongodb + db.namespace: operation-drop-indexes + db.collection.name: test + db.operation.name: dropIndexes + db.operation.summary: dropIndexes operation-drop-indexes.test + nested: + - name: dropIndexes + attributes: + db.system: mongodb + db.namespace: operation-drop-indexes + db.collection.name: test + db.command.name: dropIndexes + network.transport: tcp + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + server.address: { $$type: string } + server.port: { $$type: [int, long] } + db.query.summary: dropIndexes operation-drop-indexes.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + dropIndexes: test + index: '*' + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] diff --git a/spec/spec_tests/data/open_telemetry/operation/find.yml b/spec/spec_tests/data/open_telemetry/operation/find.yml new file mode 100644 index 0000000000..c29bc0123a --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/find.yml @@ -0,0 +1,143 @@ +description: operation find +schemaVersion: '1.27' +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - database: + id: &database0 database0 + client: *client0 + databaseName: operation-find + - collection: + id: &collection0 collection0 + database: database0 + collectionName: &collection0Name test +initialData: + - collectionName: test + databaseName: operation-find + documents: [] +tests: + - description: find an element + operations: + - name: find + object: *collection0 + arguments: { filter: { x: 1 } } + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: find operation-find.test + attributes: + db.system: mongodb + db.namespace: operation-find + db.collection.name: test + db.operation.name: find + db.operation.summary: find operation-find.test + nested: + - name: find + attributes: + db.system: mongodb + db.namespace: operation-find + db.collection.name: test + db.command.name: find + network.transport: tcp + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + server.address: { $$type: string } + server.port: { $$type: [int, long] } + db.query.summary: find operation-find.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + find: test + filter: + x: 1 + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] + + - description: find an element retrying failed command + operations: + - name: failPoint + object: testRunner + arguments: + client: *client0 + failPoint: + configureFailPoint: failCommand + mode: { times: 1 } + data: + failCommands: [ find ] + errorCode: 89 + errorLabels: [ RetryableWriteError ] + + - name: find + object: *collection0 + arguments: + filter: { x: 1 } + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: true + spans: + - name: find operation-find.test + attributes: + db.system: mongodb + db.namespace: operation-find + db.collection.name: test + db.operation.name: find + db.operation.summary: find operation-find.test + nested: + - name: find + attributes: + db.system: mongodb + db.namespace: operation-find + db.collection.name: test + db.command.name: find + network.transport: tcp + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: '89' + exception.message: { $$type: string } + exception.type: { $$type: string } + exception.stacktrace: { $$type: string } + server.address: { $$type: string } + server.port: { $$type: [ long, string ] } + db.query.summary: find operation-find.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + find: test + filter: + x: 1 + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] + - name: find + attributes: + db.system: mongodb + db.namespace: operation-find + db.collection.name: test + db.command.name: find + network.transport: tcp + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + server.address: { $$type: string } + server.port: { $$type: [ int, long ] } + db.query.summary: find operation-find.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + find: test + filter: + x: 1 + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] diff --git a/spec/spec_tests/data/open_telemetry/operation/find_and_modify.yml b/spec/spec_tests/data/open_telemetry/operation/find_and_modify.yml new file mode 100644 index 0000000000..b88131371a --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/find_and_modify.yml @@ -0,0 +1,70 @@ +description: operation find_one_and_update +schemaVersion: '1.27' +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - database: + id: &database0 database0 + client: *client0 + databaseName: operation-find-one-and-update + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: test + +tests: + - description: findOneAndUpdate + runOnRequirements: + - minServerVersion: "4.4" + operations: + - name: findOneAndUpdate + object: *collection0 + arguments: + filter: &filter + _id: 1 + update: &update + - $set: { x: 5 } + comment: "comment" + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: findOneAndUpdate operation-find-one-and-update.test + attributes: + db.system: mongodb + db.namespace: operation-find-one-and-update + db.collection.name: test + db.operation.name: findOneAndUpdate + db.operation.summary: findOneAndUpdate operation-find-one-and-update.test + + nested: + - name: findAndModify + attributes: + db.system: mongodb + db.namespace: operation-find-one-and-update + db.collection.name: test + db.command.name: findAndModify + network.transport: tcp + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + server.address: { $$type: string } + server.port: { $$type: [ int, long ] } + db.query.summary: findAndModify operation-find-one-and-update.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + findAndModify: test + query: *filter + update: *update + comment: "comment" + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] diff --git a/spec/spec_tests/data/open_telemetry/operation/find_without_query_text.yml b/spec/spec_tests/data/open_telemetry/operation/find_without_query_text.yml new file mode 100644 index 0000000000..428537de5d --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/find_without_query_text.yml @@ -0,0 +1,58 @@ +description: operation find without db.query.text +schemaVersion: '1.27' +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: false + - database: + id: &database0 database0 + client: *client0 + databaseName: operation-find + - collection: + id: &collection0 collection0 + database: database0 + collectionName: &collection0Name test +initialData: + - collectionName: test + databaseName: operation-find + documents: [] +tests: + - description: find an element + operations: + - name: find + object: *collection0 + arguments: { filter: { x: 1 } } + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: find operation-find.test + attributes: + db.system: mongodb + db.namespace: operation-find + db.collection.name: test + db.operation.name: find + db.operation.summary: find operation-find.test + nested: + - name: find + attributes: + db.system: mongodb + db.namespace: operation-find + db.collection.name: test + db.command.name: find + network.transport: tcp + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + server.address: { $$type: string } + server.port: { $$type: [int, long] } + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] + db.query.summary: find operation-find.test + db.query.text: { $$exists: false } diff --git a/spec/spec_tests/data/open_telemetry/operation/insert.yml b/spec/spec_tests/data/open_telemetry/operation/insert.yml new file mode 100644 index 0000000000..dbb11fafa2 --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/insert.yml @@ -0,0 +1,72 @@ +description: operation insert +schemaVersion: '1.27' +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - database: + id: &database0 database0 + client: *client0 + databaseName: operation-insert + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: test +initialData: + - collectionName: test + databaseName: operation-insert + documents: [ ] +tests: + - description: insert one element + operations: + - object: *collection0 + name: insertOne + arguments: { document: { _id: 1 } } + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: insert operation-insert.test + attributes: + db.system: mongodb + db.namespace: operation-insert + db.collection.name: test + db.operation.name: insert + db.operation.summary: insert operation-insert.test + nested: + - name: insert + attributes: + db.system: mongodb + db.command.name: insert + db.namespace: operation-insert + db.collection.name: test + server.address: { $$type: string } + server.port: { $$type: [ long, string ] } + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + network.transport: tcp + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] + db.query.summary: insert operation-insert.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + insert: test + ordered: true + txnNumber: { $$unsetOrMatches: 1 } + documents: + - _id: 1 + + outcome: + - collectionName: test + databaseName: operation-insert + documents: + - _id: 1 diff --git a/spec/spec_tests/data/open_telemetry/operation/list_collections.yml b/spec/spec_tests/data/open_telemetry/operation/list_collections.yml new file mode 100644 index 0000000000..da17fbb2e4 --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/list_collections.yml @@ -0,0 +1,53 @@ +description: operation list_collections +schemaVersion: '1.27' +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - database: + id: &database0 database0 + client: *client0 + databaseName: operation-list-collections +tests: + - description: List collections + operations: + - object: *database0 + name: listCollections + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: listCollections operation-list-collections + attributes: + db.system: mongodb + db.namespace: operation-list-collections + db.operation.name: listCollections + db.operation.summary: listCollections operation-list-collections + db.collection.name: { $$exists: false } + + nested: + - name: listCollections + attributes: + db.system: mongodb + db.namespace: operation-list-collections + db.collection.name: { $$exists: false } + db.command.name: listCollections + network.transport: tcp + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + server.address: { $$type: string } + server.port: { $$type: [ int, long ] } + db.query.summary: listCollections operation-list-collections + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + listCollections: 1 + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] diff --git a/spec/spec_tests/data/open_telemetry/operation/list_databases.yml b/spec/spec_tests/data/open_telemetry/operation/list_databases.yml new file mode 100644 index 0000000000..4adc361af6 --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/list_databases.yml @@ -0,0 +1,50 @@ +description: operation list_databases +schemaVersion: '1.27' +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true +tests: + - description: list databases + operations: + - object: *client0 + name: listDatabases + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: listDatabases admin + attributes: + db.system: mongodb + db.namespace: admin + db.operation.name: listDatabases + db.operation.summary: listDatabases admin + db.collection.name: { $$exists: false } + + nested: + - name: listDatabases + attributes: + db.system: mongodb + db.namespace: admin + db.collection.name: { $$exists: false } + db.command.name: listDatabases + network.transport: tcp + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + server.address: { $$type: string } + server.port: { $$type: [ int, long ] } + db.query.summary: listDatabases admin + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + listDatabases: 1 + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] diff --git a/spec/spec_tests/data/open_telemetry/operation/list_indexes.yml b/spec/spec_tests/data/open_telemetry/operation/list_indexes.yml new file mode 100644 index 0000000000..28c48e20dc --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/list_indexes.yml @@ -0,0 +1,61 @@ +description: operation list_indexes +schemaVersion: '1.27' +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - database: + id: &database0 database0 + client: *client0 + databaseName: operation-list-indexes + - collection: + id: &collection0 collection0 + database: database0 + collectionName: test +initialData: + - collectionName: test + databaseName: operation-list-indexes + documents: [ ] +tests: + - description: List indexes + operations: + - object: *collection0 + name: listIndexes + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: listIndexes operation-list-indexes.test + attributes: + db.system: mongodb + db.namespace: operation-list-indexes + db.collection.name: test + db.operation.name: listIndexes + db.operation.summary: listIndexes operation-list-indexes.test + + nested: + - name: listIndexes + attributes: + db.system: mongodb + db.namespace: operation-list-indexes + db.collection.name: test + db.command.name: listIndexes + network.transport: tcp + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + server.address: { $$type: string } + server.port: { $$type: [ int, long ] } + db.query.summary: listIndexes operation-list-indexes.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + listIndexes: test + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] diff --git a/spec/spec_tests/data/open_telemetry/operation/map_reduce.yml b/spec/spec_tests/data/open_telemetry/operation/map_reduce.yml new file mode 100644 index 0000000000..7fd74bc217 --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/map_reduce.yml @@ -0,0 +1,99 @@ +description: operation map_reduce +schemaVersion: '1.27' +runOnRequirements: + - + minServerVersion: '4.0' + topologies: + - single + - replicaset + - + minServerVersion: 4.1.7 + # serverless proxy does not support mapReduce operation + serverless: forbid + topologies: + - sharded + - load-balanced + +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - database: + id: &database0 database0 + client: *client0 + databaseName: operation-map-reduce + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: test +initialData: + - + collectionName: test + databaseName: operation-map-reduce + documents: + - + _id: 1 + x: 0 + - + _id: 2 + x: 1 + - + _id: 3 + x: 2 +tests: + - description: mapReduce + operations: + - object: *collection0 + name: mapReduce + arguments: + map: + $code: 'function inc() { return emit(0, this.x + 1) }' + reduce: + $code: 'function sum(key, values) { return values.reduce((acc, x) => acc + x); }' + out: + inline: 1 + expectResult: + - + _id: 0 + value: 6 + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: mapReduce operation-map-reduce.test + attributes: + db.system: mongodb + db.namespace: operation-map-reduce + db.collection.name: test + db.operation.name: mapReduce + db.operation.summary: mapReduce operation-map-reduce.test + + nested: + - name: mapReduce + attributes: + db.system: mongodb + db.namespace: operation-map-reduce + db.collection.name: test + db.command.name: mapReduce + network.transport: tcp + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + server.address: { $$type: string } + server.port: { $$type: [ int, long ] } + db.query.summary: mapReduce operation-map-reduce.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + mapReduce: test + map: { $code: 'function inc() { return emit(0, this.x + 1) }' } + reduce: { $code: 'function sum(key, values) { return values.reduce((acc, x) => acc + x); }' } + out: { inline: 1 } + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] diff --git a/spec/spec_tests/data/open_telemetry/operation/retries.yml b/spec/spec_tests/data/open_telemetry/operation/retries.yml new file mode 100644 index 0000000000..8375ffb1d6 --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/retries.yml @@ -0,0 +1,105 @@ +description: retries +schemaVersion: '1.27' +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - client: + id: &failPointClient failPointClient + useMultipleMongoses: false + - database: + id: &database0 database0 + client: *client0 + databaseName: &database0Name operation-retries + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: &collection0Name test +initialData: + - collectionName: test + databaseName: operation-retries + documents: [ ] +tests: + - description: find an element with retries + operations: + - name: failPoint + object: testRunner + arguments: + client: *failPointClient + failPoint: + configureFailPoint: failCommand + mode: { times: 1 } + data: + failCommands: [ find ] + errorCode: 89 + errorLabels: [ RetryableWriteError ] + + - name: find + object: *collection0 + arguments: + filter: { x: 1 } + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: true + spans: + - name: find operation-retries.test + attributes: + db.system: mongodb + db.namespace: *database0Name + db.collection.name: *collection0Name + db.operation.name: find + db.operation.summary: find operation-retries.test + + nested: + - name: find + attributes: + db.system: mongodb + db.namespace: *database0Name + db.collection.name: *collection0Name + db.command.name: find + network.transport: tcp + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: '89' + exception.message: { $$type: string } + exception.type: { $$type: string } + exception.stacktrace: { $$type: string } + server.address: { $$type: string } + server.port: { $$type: [ long, string ] } + db.query.summary: find operation-retries.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + find: test + filter: + x: 1 + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] + - name: find + attributes: + db.system: mongodb + db.namespace: *database0Name + db.collection.name: *collection0Name + db.command.name: find + network.transport: tcp + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + server.address: { $$type: string } + server.port: { $$type: [ int, long ] } + db.query.summary: find operation-retries.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + find: test + filter: + x: 1 + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] diff --git a/spec/spec_tests/data/open_telemetry/operation/update.yml b/spec/spec_tests/data/open_telemetry/operation/update.yml new file mode 100644 index 0000000000..c3c816ec17 --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/operation/update.yml @@ -0,0 +1,66 @@ +description: operation update +schemaVersion: '1.27' +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - database: + id: &database0 database0 + client: *client0 + databaseName: &databaseName0 operation-update + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: &collectionName0 test + +tests: + - description: update one element + operations: + - + object: *collection0 + name: updateOne + arguments: + filter: { _id: 1 } + update: { $inc: { x: 1 } } + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: update operation-update.test + attributes: + db.system: mongodb + db.namespace: *databaseName0 + db.collection.name: *collectionName0 + db.operation.name: update + db.operation.summary: update operation-update.test + + nested: + - name: update + attributes: + db.system: mongodb + db.namespace: operation-update + db.collection.name: *collectionName0 + db.command.name: update + server.address: { $$type: string } + server.port: { $$type: [ long, string ] } + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + network.transport: tcp + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] + db.query.summary: update operation-update.test + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + update: test + ordered: true + txnNumber: { $$unsetOrMatches: 1 } + updates: [ { "q": { "_id": 1 }, "u": { "$inc": { "x": 1 } } } ] diff --git a/spec/spec_tests/data/open_telemetry/transaction/convenient.yml b/spec/spec_tests/data/open_telemetry/transaction/convenient.yml new file mode 100644 index 0000000000..a606b82957 --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/transaction/convenient.yml @@ -0,0 +1,167 @@ +description: convenient transactions + +schemaVersion: "1.27" + +runOnRequirements: + - minServerVersion: "4.4" + topologies: ["replicaset", "sharded"] + +createEntities: + - client: + id: &client client + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - database: + id: &database database + client: *client + databaseName: &databaseName convenient-transaction-tests + - collection: + id: &collection collection + database: *database + collectionName: &collectionName test + - session: + id: &session session + client: *client + +initialData: + - collectionName: *collectionName + databaseName: *databaseName + documents: [] + +tests: + - description: "withTransaction" + operations: + - name: withTransaction + object: *session + arguments: + callback: + - name: insertOne + object: *collection + arguments: + document: + _id: 1 + session: *session + - name: find + object: *collection + arguments: { filter: { x: 1 } } + + expectTracingMessages: + - client: *client + ignoreExtraSpans: false + spans: + - name: transaction + attributes: + db.system: mongodb + nested: + - name: insert convenient-transaction-tests.test + attributes: + db.system: mongodb + db.namespace: *databaseName + db.collection.name: *collectionName + db.operation.name: insert + db.operation.summary: insert convenient-transaction-tests.test + + nested: + - name: insert + attributes: + db.system: mongodb + db.namespace: *databaseName + db.collection.name: *collectionName + db.command.name: insert + server.address: { $$type: string } + server.port: { $$type: [ long, string ] } + db.query.summary: insert convenient-transaction-tests.test + db.mongodb.lsid: { $$sessionLsid: *session } + db.mongodb.txn_number: 1 + network.transport: tcp + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + insert: test + ordered: true + txnNumber: 1 + startTransaction: true + autocommit: false + documents: + - _id: 1 + - name: commitTransaction admin + attributes: + db.system: mongodb + db.namespace: admin + db.collection.name: { $$exists: false } + db.operation.name: commitTransaction + db.operation.summary: commitTransaction admin + + nested: + - name: commitTransaction + attributes: + db.system: mongodb + db.namespace: admin + db.collection.name: { $$exists: false } + db.query.summary: commitTransaction admin + db.command.name: commitTransaction + db.mongodb.lsid: { $$sessionLsid: *session } + db.mongodb.txn_number: 1 + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + network.transport: tcp + server.address: { $$type: string } + server.port: { $$type: [ int, long ] } + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + commitTransaction: 1 + txnNumber: 1 + autocommit: false + + - name: find convenient-transaction-tests.test + attributes: + db.system: mongodb + db.namespace: *databaseName + db.collection.name: *collectionName + db.operation.summary: find convenient-transaction-tests.test + db.operation.name: find + + nested: + - name: find + attributes: + db.system: mongodb + db.namespace: *databaseName + db.collection.name: *collectionName + db.command.name: find + network.transport: tcp + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + server.address: { $$type: string } + server.port: { $$type: [ long, string ] } + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] + db.query.summary: find convenient-transaction-tests.test + db.query.text: { $$exists: true } + + outcome: + - collectionName: test + databaseName: convenient-transaction-tests + documents: + - _id: 1 diff --git a/spec/spec_tests/data/open_telemetry/transaction/core_api.yml b/spec/spec_tests/data/open_telemetry/transaction/core_api.yml new file mode 100644 index 0000000000..fe10899ed0 --- /dev/null +++ b/spec/spec_tests/data/open_telemetry/transaction/core_api.yml @@ -0,0 +1,269 @@ +description: transaction spans +schemaVersion: '1.27' +runOnRequirements: + - minServerVersion: '4.0' + topologies: + - replicaset + - minServerVersion: '4.1.8' + topologies: + - sharded + - load-balanced +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeTracingMessages: + enableCommandPayload: true + - database: + id: &database0 database0 + client: *client0 + databaseName: transaction-tests + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: test + - session: + id: &session0 session0 + client: client0 +initialData: + - collectionName: test + databaseName: transaction-tests + documents: [] +tests: + - description: commit transaction + operations: + - object: *session0 + name: startTransaction + - object: *collection0 + name: insertOne + arguments: + session: *session0 + document: + _id: 1 + - object: *session0 + name: commitTransaction + - name: find + object: *collection0 + arguments: { filter: { x: 1 } } + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: transaction + attributes: + db.system: mongodb + nested: + - name: insert transaction-tests.test + attributes: + db.system: mongodb + db.namespace: transaction-tests + db.collection.name: test + db.operation.name: insert + db.operation.summary: insert transaction-tests.test + + nested: + - name: insert + attributes: + db.system: mongodb + db.namespace: transaction-tests + db.collection.name: test + db.command.name: insert + server.address: { $$type: string } + server.port: { $$type: [long, string] } + db.query.summary: insert transaction-tests.test + db.mongodb.lsid: { $$sessionLsid: *session0 } + db.mongodb.txn_number: 1 + network.transport: tcp + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + insert: test + ordered: true + txnNumber: 1 + startTransaction: true + autocommit: false + documents: + - _id: 1 + - name: commitTransaction admin + attributes: + db.system: mongodb + db.namespace: admin + db.collection.name: { $$exists: false } + db.operation.name: commitTransaction + db.operation.summary: commitTransaction admin + + nested: + - name: commitTransaction + attributes: + db.system: mongodb + db.namespace: admin + db.collection.name: { $$exists: false } + db.query.summary: commitTransaction admin + db.command.name: commitTransaction + db.mongodb.lsid: { $$sessionLsid: *session0 } + db.mongodb.txn_number: 1 + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + network.transport: tcp + server.address: { $$type: string } + server.port: { $$type: [ int, long ] } + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + commitTransaction: 1 + txnNumber: 1 + autocommit: false + - name: find transaction-tests.test + attributes: + db.system: mongodb + db.namespace: transaction-tests + db.collection.name: test + db.operation.summary: find transaction-tests.test + db.operation.name: find + nested: + - name: find + attributes: + db.system: mongodb + db.namespace: transaction-tests + db.collection.name: test + db.command.name: find + server.address: { $$type: string } + server.port: { $$type: [long, string] } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + network.transport: tcp + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] + db.query.text: { $$exists: true } + db.query.summary: find transaction-tests.test + outcome: + - collectionName: test + databaseName: transaction-tests + documents: + - _id: 1 + + - description: abort transaction + operations: + - object: *session0 + name: startTransaction + - object: *collection0 + name: insertOne + arguments: + session: *session0 + document: + _id: 1 + - object: *session0 + name: abortTransaction + + expectTracingMessages: + - client: *client0 + ignoreExtraSpans: false + spans: + - name: transaction + attributes: + db.system: mongodb + db.namespace: { $$exists: false } + db.collection.name: { $$exists: false } + db.operation.name: { $$exists: false } + db.operation.summary: { $$exists: false } + nested: + - name: insert transaction-tests.test + attributes: + db.system: mongodb + db.namespace: transaction-tests + db.collection.name: test + db.operation.name: insert + db.operation.summary: insert transaction-tests.test + nested: + - name: insert + attributes: + db.system: mongodb + db.namespace: transaction-tests + db.collection.name: test + db.command.name: insert + server.address: { $$type: string } + server.port: { $$type: [long, string] } + db.query.summary: insert transaction-tests.test + db.mongodb.lsid: { $$sessionLsid: *session0 } + db.mongodb.txn_number: 1 + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + network.transport: tcp + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + insert: test + ordered: true + txnNumber: 1 + startTransaction: true + autocommit: false + documents: + - _id: 1 + - name: abortTransaction admin + attributes: + db.system: mongodb + db.namespace: admin + db.collection.name: { $$exists: false } + db.operation.name: abortTransaction + db.operation.summary: abortTransaction admin + nested: + - name: abortTransaction + attributes: + db.system: mongodb + db.namespace: admin + db.collection.name: { $$exists: false } + db.query.summary: abortTransaction admin + db.command.name: abortTransaction + db.mongodb.lsid: { $$sessionLsid: *session0 } + db.mongodb.txn_number: 1 + db.mongodb.cursor_id: { $$exists: false } + db.response.status_code: { $$exists: false } + exception.message: { $$exists: false } + exception.type: { $$exists: false } + exception.stacktrace: { $$exists: false } + network.transport: tcp + server.address: { $$type: string } + server.port: { $$type: [ int, long ] } + db.mongodb.server_connection_id: + $$type: [ int, long ] + db.mongodb.driver_connection_id: + $$type: [ int, long ] + db.query.text: + $$matchAsDocument: + $$matchAsRoot: + abortTransaction: 1 + txnNumber: 1 + autocommit: false + + outcome: + - collectionName: test + databaseName: transaction-tests + documents: [] diff --git a/spec/spec_tests/open_telemetry_spec.rb b/spec/spec_tests/open_telemetry_spec.rb new file mode 100644 index 0000000000..e202f42e72 --- /dev/null +++ b/spec/spec_tests/open_telemetry_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +# rubocop:todo all + +require 'spec_helper' + +require 'runners/unified' + +base = "#{CURRENT_PATH}/spec_tests/data/open_telemetry" +OTEL_UNIFIED_TESTS = Dir.glob("#{base}/**/*.yml").sort +SKIPPED_OTEL_TESTS = [ + 'bulk_write.yml', 'map_reduce.yml', 'atlas_search.yml' +] + +TESTS_TO_RUN = OTEL_UNIFIED_TESTS.reject do |path| + SKIPPED_OTEL_TESTS.include?(File.basename(path)) +end + +SKIPPED_OTEL_TESTS.each do |filename| + warn "Skipping OpenTelemetry unified spec test: #{filename}" +end + +describe 'OpenTelemetry unified spec tests' do + define_unified_spec_tests(base, TESTS_TO_RUN) +end diff --git a/spec/support/tracing.rb b/spec/support/tracing.rb new file mode 100644 index 0000000000..4557f8eaae --- /dev/null +++ b/spec/support/tracing.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +module Tracing + Error = Class.new(StandardError) + + class Span + attr_reader :tracer, :name, :attributes, :with_parent, :kind, :finished, :nested + + attr_accessor :status + + def initialize(tracer, name, attributes = {}, with_parent: nil, kind: :internal) + @tracer = tracer + @name = name + @attributes = attributes + @with_parent = with_parent + @kind = kind + @finished = false + @nested = [] + end + + def set_attribute(key, value) + @attributes[key] = value + end + + def record_exception(exception, attributes: nil) + set_attribute('exception.type', exception.class.to_s) + set_attribute('exception.message', exception.message) + set_attribute( + 'exception.stacktrace', + exception.full_message(highlight: false, order: :top).encode('UTF-8', invalid: :replace, undef: :replace, + replace: '�') + ) + end + + def finish + raise Tracing::Error, 'Span already finished' if @finished + + @finished = true + tracer.finish_span(self) + end + + def inspect + "#" + end + end + + # Mock OpenTelemetry::Context to store and retrieve spans + class Context + attr_reader :span + + def initialize(span) + @span = span + end + end + + class Tracer + attr_reader :spans + + def initialize + @spans = [] + @stack = [] + @active_context = nil + end + + def start_span(name, attributes: {}, with_parent: nil, kind: :internal) + parent = resolve_parent(with_parent) + + Span.new(self, name, attributes, with_parent: parent, kind: kind).tap do |span| + @spans << span + @stack << span + end + end + + def finish_span(span) + raise Error, 'Span not found' unless @spans.include?(span) + + @stack.pop if @stack.last == span + end + + def span_hierarchy + # Build a mapping of all spans by their object_id for quick lookup + span_map = {} + @spans.each do |span| + span_map[span.object_id] = span + end + + # Build the hierarchy by attaching children to their parents + root_spans = [] + @spans.each do |span| + if span.with_parent.nil? + # This is a root span + root_spans << span + else + # Find the parent span and add this span to its nested array + parent = span_map[span.with_parent.object_id] + unless parent + raise Error, "Parent span not found for span #{span.name} (parent object_id: #{span.with_parent.object_id})" + end + + parent.nested << span + + end + end + + root_spans + end + + private + + # Resolve the parent span from various input types + def resolve_parent(with_parent) + return @stack.last if with_parent.nil? + + case with_parent + when Tracing::Context + # Extract span from our mock Context + with_parent.span + when Tracing::Span + # Already a span + with_parent + when OpenTelemetry::Context + # Extract span from OpenTelemetry::Context + # The OpenTelemetry context stores the span using a specific key + # We need to extract it using the OpenTelemetry::Trace API + begin + OpenTelemetry::Trace.current_span(with_parent) + rescue StandardError + # Fallback: try to extract from instance variables + with_parent.instance_variable_get(:@entries)&.values&.first + end + else + with_parent + end + end + end +end