From b78e04ab1242357ff00a29c8444e27cf81896623 Mon Sep 17 00:00:00 2001 From: Dmitry Rybakov Date: Thu, 26 Jun 2025 12:33:22 +0200 Subject: [PATCH] RUBY-3612 OpenTelemetry support --- gemfiles/standard.rb | 1 + lib/mongo.rb | 1 + lib/mongo/client.rb | 24 ++++ lib/mongo/collection.rb | 31 +++-- lib/mongo/collection/view.rb | 2 + lib/mongo/collection/view/iterable.rb | 26 ++-- lib/mongo/cursor.rb | 11 +- lib/mongo/operation/context.rb | 1 + lib/mongo/operation/insert/op_msg.rb | 10 +- lib/mongo/operation/shared/executable.rb | 12 +- lib/mongo/operation/shared/specifiable.rb | 4 + lib/mongo/server/connection.rb | 11 ++ lib/mongo/tracing.rb | 16 +++ lib/mongo/tracing/open_telemetry.rb | 26 ++++ .../tracing/open_telemetry/command_tracer.rb | 94 ++++++++++++++ .../open_telemetry/operation_tracer.rb | 119 ++++++++++++++++++ lib/mongo/tracing/open_telemetry/tracer.rb | 88 +++++++++++++ spec/lite_spec_helper.rb | 1 + spec/mongo/tracer/open_telemetry_spec.rb | 26 ++++ spec/runners/unified/test.rb | 13 ++ .../data/crud_unified/find-comment.yml | 10 ++ spec/support/tracing.rb | 64 ++++++++++ 22 files changed, 557 insertions(+), 34 deletions(-) create mode 100644 lib/mongo/tracing.rb create mode 100644 lib/mongo/tracing/open_telemetry.rb create mode 100644 lib/mongo/tracing/open_telemetry/command_tracer.rb create mode 100644 lib/mongo/tracing/open_telemetry/operation_tracer.rb create mode 100644 lib/mongo/tracing/open_telemetry/tracer.rb create mode 100644 spec/mongo/tracer/open_telemetry_spec.rb create mode 100644 spec/support/tracing.rb 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..ba9aa817e6 100644 --- a/lib/mongo.rb +++ b/lib/mongo.rb @@ -74,6 +74,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..dcb10660aa 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 @@ -1195,6 +1210,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/collection.rb b/lib/mongo/collection.rb index b9cbefee0c..a2e0602cba 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 @@ -865,19 +867,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('insert_one', 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/iterable.rb b/lib/mongo/collection/view/iterable.rb index 99133c5e9f..54dcd6bb2b 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('get_more', 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/cursor.rb b/lib/mongo/cursor.rb index 0e0927f02c..59a438ae7b 100644 --- a/lib/mongo/cursor.rb +++ b/lib/mongo/cursor.rb @@ -39,6 +39,7 @@ class Cursor def_delegators :@view, :collection def_delegators :collection, :client, :database def_delegators :@server, :cluster + def_delegators :client, :tracer # @return [ Collection::View ] view The collection view. attr_reader :view @@ -514,10 +515,12 @@ def unregister def execute_operation(op, context: nil) op_context = context || possibly_refreshed_context - if @connection.nil? - op.execute(@server, context: op_context) - else - op.execute_with_connection(@connection, context: op_context) + tracer.trace_operation('find', op, op_context) do + if @connection.nil? + op.execute(@server, context: op_context) + else + op.execute_with_connection(@connection, context: op_context) + end end end diff --git a/lib/mongo/operation/context.rb b/lib/mongo/operation/context.rb index 03d6e0957d..c2be3c0149 100644 --- a/lib/mongo/operation/context.rb +++ b/lib/mongo/operation/context.rb @@ -69,6 +69,7 @@ def initialize( attr_reader :session attr_reader :view attr_reader :options + attr_accessor :tracer # Returns a new Operation::Context with the deadline refreshed # and relative to the current moment. diff --git a/lib/mongo/operation/insert/op_msg.rb b/lib/mongo/operation/insert/op_msg.rb index 39b299ef76..45789ae183 100644 --- a/lib/mongo/operation/insert/op_msg.rb +++ b/lib/mongo/operation/insert/op_msg.rb @@ -34,8 +34,14 @@ 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) + if (tracer = context.tracer) + tracer.trace_command(message, context, connection) do + Result.new(*dispatch_message(message, connection, context), @ids, context: context) + end + else + Result.new(*dispatch_message(message, connection, context), @ids, context: context) + end end def selector(connection) diff --git a/lib/mongo/operation/shared/executable.rb b/lib/mongo/operation/shared/executable.rb index 041e4d1e5b..53f35249e3 100644 --- a/lib/mongo/operation/shared/executable.rb +++ b/lib/mongo/operation/shared/executable.rb @@ -104,12 +104,18 @@ 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) + if (tracer = context.tracer) + tracer.trace_command(message, context, connection) do + result_class.new(*dispatch_message(message, connection, context, options), context: context, connection: connection) + end + else + result_class.new(*dispatch_message(message, connection, context, options), context: context, connection: connection) + 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..aa3125cb2a 100644 --- a/lib/mongo/operation/shared/specifiable.rb +++ b/lib/mongo/operation/shared/specifiable.rb @@ -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/connection.rb b/lib/mongo/server/connection.rb index f9874764cf..14b8f7b16d 100644 --- a/lib/mongo/server/connection.rb +++ b/lib/mongo/server/connection.rb @@ -388,6 +388,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/tracing.rb b/lib/mongo/tracing.rb new file mode 100644 index 0000000000..25a0e7eeac --- /dev/null +++ b/lib/mongo/tracing.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Mongo + module Tracing + 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..76113783c8 --- /dev/null +++ b/lib/mongo/tracing/open_telemetry/command_tracer.rb @@ -0,0 +1,94 @@ +# 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 + class CommandTracer + def initialize(otel_tracer, query_text_max_length: 0) + @otel_tracer = otel_tracer + @query_text_max_length = query_text_max_length + end + + def trace_command(message, _operation_context, connection) + @otel_tracer.in_span( + command_span_name(message), + attributes: span_attributes(message, connection), + kind: :client + ) do |span, _context| + yield.tap do |result| + if result.respond_to?(:cursor_id) && result.cursor_id.positive? + span.set_attribute('db.mongodb.cursor_id', result.cursor_id) + end + end + end + end + + private + + def span_attributes(message, connection) + { + 'db.system' => 'mongodb', + 'db.namespace' => message.documents.first['$db'], + 'db.collection.name' => collection_name(message), + 'db.operation.name' => message.documents.first.keys.first, + '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, + 'db.query.text' => query_text(message) + }.compact + end + + def command_span_name(message) + message.documents.first.keys.first + end + + def collection_name(message) + case message.documents.first.keys.first + when 'getMore' + message.documents.first['collection'] + else + message.documents.first.values.first + end + end + + def query_text? + @query_text_max_length.positive? + end + + EXCLUDED_KEYS = %w[lsid $db $clusterTime signature].freeze + ELLIPSES = '...' + + def query_text(message) + return unless query_text? + + text = message + .documents + .first + .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..9e0ddae80f --- /dev/null +++ b/lib/mongo/tracing/open_telemetry/operation_tracer.rb @@ -0,0 +1,119 @@ +# 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 operations using OpenTelemetry. + # + # It provides methods to trace driven operations. + # @api private + class OperationTracer + def initialize(otel_tracer, parent_tracer) + @otel_tracer = otel_tracer + @parent_tracer = parent_tracer + end + + def trace_operation(name, operation, operation_context) + parent_context = parent_context_for(operation_context, operation.cursor_id) + operation_context.tracer = @parent_tracer + span = @otel_tracer.start_span( + operation_span_name(name, operation), + attributes: span_attributes(name, operation), + with_parent: parent_context, + kind: :client + ) + ::OpenTelemetry::Trace.with_span(span) do |_s, c| + yield.tap do |result| + process_cursor_context(result, operation.cursor_id, c) + end + end + rescue Exception => e + span&.record_exception(e) + span&.status = ::OpenTelemetry::Trace::Status.error("Unhandled exception of type: #{e.class}") + raise e + ensure + span&.finish + operation_context.tracer = nil + end + + private + + def span_attributes(name, operation) + { + 'db.system' => 'mongodb', + 'db.namespace' => operation.db_name.to_s, + 'db.collection.name' => operation.coll_name.to_s, + 'db.operation.name' => name, + 'db.operation.summary' => operation_span_name(name, operation), + 'db.cursor.id' => operation.cursor_id, + }.compact + end + + def parent_context_for(operation_context, cursor_id) + if (key = transaction_map_key(operation_context.session)) + transaction_context_map[key] + elsif cursor_id + cursor_context_map[cursor_id] + end + end + + # This map is used to store OpenTelemetry context for cursor_id. + # This allows to group all operations related to a cursor under the same context. + # + # # @return [Hash] a map of cursor_id to OpenTelemetry context. + def cursor_context_map + @cursor_context_map ||= {} + end + + def process_cursor_context(result, cursor_id, context) + 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 + end + end + + # This map is used to store OpenTelemetry context for transaction. + # This allows to group all operations related to a transaction under the same context. + # + # @return [Hash] a map of transaction_id to OpenTelemetry context. + def transaction_context_map + @transaction_context_map ||= {} + end + + # @param session [Mongo::Session] the session for which to get the transaction map key. + def transaction_map_key(session) + return if session.nil? || session.implicit? || !session.in_transaction? + + "#{session.id}-#{session.txn_num}" + end + + def operation_span_name(name, operation) + if operation.coll_name + "#{name} #{operation.db_name}.#{operation.coll_name}" + else + "#{operation.db_name}.#{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..de9f5b07dd --- /dev/null +++ b/lib/mongo/tracing/open_telemetry/tracer.rb @@ -0,0 +1,88 @@ +# 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 + 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. + # If nil, it will check the environment variable + # OTEL_RUBY_INSTRUMENTATION_MONGODB_ENABLED. + # @param otel_tracer [ OpenTelemetry::Trace::Tracer | nil ] the OpenTelemetry tracer + # implementation to use. If nil, 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, 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 + + def trace_operation(name, operation, operation_context, &block) + return yield unless enabled? + + operation_context.tracer = self + @operation_tracer.trace_operation(name, operation, operation_context, &block) + end + + def trace_command(message, operation_context, connection, &block) + return yield unless enabled? + + @command_tracer.trace_command(message, operation_context, connection, &block) + 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..ddd8b25efd 100644 --- a/spec/lite_spec_helper.rb +++ b/spec/lite_spec_helper.rb @@ -94,6 +94,7 @@ 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' diff --git a/spec/mongo/tracer/open_telemetry_spec.rb b/spec/mongo/tracer/open_telemetry_spec.rb new file mode 100644 index 0000000000..a7d115a9a0 --- /dev/null +++ b/spec/mongo/tracer/open_telemetry_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mongo::Tracer::OpenTelemetry do + describe '#initialize' do + it 'disables OpenTelemetry by default' do + tracer = described_class.new + expect(tracer.enabled?).to be false + end + + it 'disables OpenTelemetry when the environment variable is not set' do + allow(ENV).to receive(:[]).with('OTEL_RUBY_INSTRUMENTATION_MONGODB_ENABLED').and_return(nil) + tracer = described_class.new + expect(tracer.enabled?).to be false + end + + %w[ true 1 yes ].each do |value| + it "enables OpenTelemetry when the environment variable is set to '#{value}'" do + allow(ENV).to receive(:[]).with('OTEL_RUBY_INSTRUMENTATION_MONGODB_ENABLED').and_return(value) + tracer = described_class.new + expect(tracer.enabled?).to be true + end + end + end +end diff --git a/spec/runners/unified/test.rb b/spec/runners/unified/test.rb index 32b0ba0a82..6f3bf61d62 100644 --- a/spec/runners/unified/test.rb +++ b/spec/runners/unified/test.rb @@ -37,6 +37,7 @@ def initialize(spec, **opts) @description = @test_spec.use('description') @outcome = @test_spec.use('outcome') @expected_events = @test_spec.use('expectEvents') + @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) } @@ -195,6 +196,14 @@ def generate_entities(es) end end + observe_spans = spec.use('observeSpans') + if observe_spans + opts[:tracing] = { + enabled: true, + tracer: tracer, + } + end + create_client(**opts).tap do |client| @observe_sensitive[id] = spec.use('observeSensitiveCommands') @subscribers[client] ||= subscriber @@ -602,5 +611,9 @@ def bson_error BSON::String.const_get(:IllegalKey) : BSON::Error end + + def tracer + @tracer ||= ::Tracing::Tracer.new + end end end diff --git a/spec/spec_tests/data/crud_unified/find-comment.yml b/spec/spec_tests/data/crud_unified/find-comment.yml index 905241ad0e..70cc968f7f 100644 --- a/spec/spec_tests/data/crud_unified/find-comment.yml +++ b/spec/spec_tests/data/crud_unified/find-comment.yml @@ -6,6 +6,7 @@ createEntities: - client: id: &client0 client0 observeEvents: [ commandStartedEvent ] + observeSpans: true - database: id: &database0 database0 client: *client0 @@ -47,6 +48,15 @@ tests: find: *collection0Name filter: *filter comment: "comment" + expectSpans: + - client: *client0 + spans: + - name: "find" + attributes: + mongodb.collection: *collection0Name + mongodb.database: *database0Name + mongodb.filter: *filter + mongodb.comment: "comment" - description: "find with document comment" runOnRequirements: diff --git a/spec/support/tracing.rb b/spec/support/tracing.rb new file mode 100644 index 0000000000..0eefb1ad46 --- /dev/null +++ b/spec/support/tracing.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Tracing + class Span + + attr_reader :name, :attributes, :events, :with_parent, :kind, :finished + + attr_accessor :status + + def initialize(name, attributes = {}, with_parent: nil, kind: :internal) + @name = name + @attributes = attributes + @events = [] + @with_parent = with_parent + @kind = kind + @finished = false + end + + def set_attribute(key, value) + @attributes[key] = value + end + + def add_event(name, attributes: {}) + event_attributes = { 'event.name' => name } + event_attributes.merge!(attributes) unless attributes.nil? + @events << event_attributes + end + + def record_exception(exception, attributes: nil) + event_attributes = { + 'exception.type' => exception.class.to_s, + 'exception.message' => exception.message, + 'exception.stacktrace' => exception.full_message(highlight: false, order: :top).encode('UTF-8', invalid: :replace, undef: :replace, replace: '�') + } + event_attributes.merge!(attributes) unless attributes.nil? + add_event('exception', attributes: event_attributes) + end + + def finish + @finished = true + end + end + + class Tracer + + attr_reader :spans + + def initialize + @spans = [] + end + def in_span(name, attributes: {}, kind: :internal) + span = Span.new(name, attributes, kind: kind) + @spans << span + context = Object.new + yield(span, context) if block_given? + end + + def start_span(name, attributes: {}, with_parent: nil, kind: :internal) + Span.new(name, attributes, with_parent: with_parent, kind: kind).tap do |span| + @spans << span + end + end + end +end