diff --git a/instrumentation/mongo/Appraisals b/instrumentation/mongo/Appraisals index e17c0158c9..5e7593563c 100644 --- a/instrumentation/mongo/Appraisals +++ b/instrumentation/mongo/Appraisals @@ -4,9 +4,18 @@ # # SPDX-License-Identifier: Apache-2.0 -appraise 'mongo-2.22' do - gem 'mongo', '~> 2.22.0' +# To facilitate database semantic convention stability migration, we are using +# appraisal to test the different semantic convention modes along with different +# gem versions. For more information on the semantic convention modes, see: +# https://opentelemetry.io/docs/specs/semconv/non-normative/db-migration/ - # TODO: bson 5.1.0 isn't compatible with JRuby as of 2025/06/17 - gem 'bson', '< 5.1.0' if defined?(JRUBY_VERSION) +semconv_stability = %w[old stable dup] + +semconv_stability.each do |mode| + appraise "mongo-latest-#{mode}" do + gem 'mongo', '< 2.23.0' # Mongo 2.23.0+ has native OpenTelemetry instrumentation + + # TODO: bson 5.1.0 isn't compatible with JRuby as of 2025/06/17 + gem 'bson', '< 5.1.0' if defined?(JRUBY_VERSION) + end end diff --git a/instrumentation/mongo/README.md b/instrumentation/mongo/README.md index c6df057f3d..3ad53681fa 100644 --- a/instrumentation/mongo/README.md +++ b/instrumentation/mongo/README.md @@ -75,6 +75,22 @@ The `opentelemetry-instrumentation-mongo` gem source is [on github][repo-github] The OpenTelemetry Ruby gems are maintained by the OpenTelemetry Ruby special interest group (SIG). You can get involved by joining us on our [GitHub Discussions][discussions-url], [Slack Channel][slack-channel] or attending our weekly meeting. See the [meeting calendar][community-meetings] for dates and times. For more information on this and other language SIGs, see the OpenTelemetry [community page][ruby-sig]. +## Database semantic convention stability + +In the OpenTelemetry ecosystem, database semantic conventions have now reached a stable state. However, the initial Mongo instrumentation was introduced before this stability was achieved, which resulted in database attributes being based on an older version of the semantic conventions. + +To facilitate the migration to stable semantic conventions, you can use the `OTEL_SEMCONV_STABILITY_OPT_IN` environment variable. This variable allows you to opt-in to the new stable conventions, ensuring compatibility and future-proofing your instrumentation. + +When setting the value for `OTEL_SEMCONV_STABILITY_OPT_IN`, you can specify which conventions you wish to adopt: + +- `database` - Emits the stable database and networking conventions and ceases emitting the old conventions previously emitted by the instrumentation. +- `database/dup` - Emits both the old and stable database and networking conventions, enabling a phased rollout of the stable semantic conventions. +- Default behavior (in the absence of either value) is to continue emitting the old database and networking conventions the instrumentation previously emitted. + +During the transition from old to stable conventions, Mongo instrumentation code comes in three patch versions: `dup`, `old`, and `stable`. These versions are identical except for the attributes they send. Any changes to Mongo instrumentation should consider all three patches. + +For additional information on migration, please refer to our [documentation](https://opentelemetry.io/docs/specs/semconv/non-normative/db-migration/). + ## License Apache 2.0 license. See [LICENSE][license-github] for more information. diff --git a/instrumentation/mongo/lib/opentelemetry/instrumentation/mongo/instrumentation.rb b/instrumentation/mongo/lib/opentelemetry/instrumentation/mongo/instrumentation.rb index 0491515138..d9c8773863 100644 --- a/instrumentation/mongo/lib/opentelemetry/instrumentation/mongo/instrumentation.rb +++ b/instrumentation/mongo/lib/opentelemetry/instrumentation/mongo/instrumentation.rb @@ -35,19 +35,53 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base option :peer_service, default: nil, validate: :string option :db_statement, default: :obfuscate, validate: %I[omit obfuscate include] + attr_reader :semconv + private def gem_version Gem::Version.new(::Mongo::VERSION) end + def determine_semconv + opt_in = ENV.fetch('OTEL_SEMCONV_STABILITY_OPT_IN', nil) + return :old if opt_in.nil? + + opt_in_values = opt_in.split(',').map(&:strip) + + if opt_in_values.include?('database/dup') + :dup + elsif opt_in_values.include?('database') + :stable + else + :old + end + end + def require_dependencies - require_relative 'subscriber' + @semconv = determine_semconv + + case @semconv + when :old + require_relative 'subscribers/old/subscriber' + when :stable + require_relative 'subscribers/stable/subscriber' + when :dup + require_relative 'subscribers/dup/subscriber' + end end def register_subscriber + subscriber_class = case @semconv + when :stable + Subscribers::Stable::Subscriber + when :dup + Subscribers::Dup::Subscriber + else + Subscribers::Old::Subscriber + end # Subscribe to all COMMAND queries with our subscriber class - ::Mongo::Monitoring::Global.subscribe(::Mongo::Monitoring::COMMAND, Subscriber.new) + ::Mongo::Monitoring::Global.subscribe(::Mongo::Monitoring::COMMAND, subscriber_class.new) end end end diff --git a/instrumentation/mongo/lib/opentelemetry/instrumentation/mongo/subscriber.rb b/instrumentation/mongo/lib/opentelemetry/instrumentation/mongo/subscriber.rb deleted file mode 100644 index 2abb6de2cc..0000000000 --- a/instrumentation/mongo/lib/opentelemetry/instrumentation/mongo/subscriber.rb +++ /dev/null @@ -1,107 +0,0 @@ -# frozen_string_literal: true - -# Copyright The OpenTelemetry Authors -# -# SPDX-License-Identifier: Apache-2.0 - -require_relative 'command_serializer' - -module OpenTelemetry - module Instrumentation - module Mongo - # Event handler class for Mongo Ruby driver - class Subscriber - THREAD_KEY = :__opentelemetry_mongo_spans__ - - def started(event) - # start a trace and store it in the current thread; using the `operation_id` - # is safe since it's a unique id used to link events together. Also only one - # thread is involved in this execution so thread-local storage should be safe. Reference: - # https://github.com/mongodb/mongo-ruby-driver/blob/master/lib/mongo/monitoring.rb#L70 - # https://github.com/mongodb/mongo-ruby-driver/blob/master/lib/mongo/monitoring/publishable.rb#L38-L56 - - collection = get_collection(event.command) - - attributes = { - 'db.system' => 'mongodb', - 'db.name' => event.database_name, - 'db.operation' => event.command_name, - 'net.peer.name' => event.address.host, - 'net.peer.port' => event.address.port - } - - config = Mongo::Instrumentation.instance.config - attributes['peer.service'] = config[:peer_service] if config[:peer_service] - # attributes['db.statement'] = CommandSerializer.new(event.command).serialize - omit = config[:db_statement] == :omit - obfuscate = config[:db_statement] == :obfuscate - attributes['db.statement'] = CommandSerializer.new(event.command, obfuscate).serialize unless omit - attributes['db.mongodb.collection'] = collection if collection - attributes.compact! - - span = tracer.start_span(span_name(collection, event.command_name), attributes: attributes, kind: :client) - set_span(event, span) - end - - def failed(event) - finish_event('failed', event) do |span| - if event.is_a?(::Mongo::Monitoring::Event::CommandFailed) - span.add_event('exception', - attributes: { - 'exception.type' => 'CommandFailed', - 'exception.message' => event.message - }) - end - end - end - - def succeeded(event) - finish_event('succeeded', event) - end - - private - - def finish_event(name, event) - span = get_span(event) - return unless span - - yield span if block_given? - rescue StandardError => e - OpenTelemetry.logger.debug("error when handling MongoDB '#{name}' event: #{e}") - ensure - # finish span to prevent leak and remove it from thread storage - span&.finish - clear_span(event) - end - - def span_name(collection, command_name) - return command_name unless collection - - "#{collection}.#{command_name}" - end - - def get_collection(command) - collection = command.values.first - collection if collection.is_a?(String) - end - - def get_span(event) - Thread.current[THREAD_KEY]&.[](event.request_id) - end - - def set_span(event, span) - Thread.current[THREAD_KEY] ||= {} - Thread.current[THREAD_KEY][event.request_id] = span - end - - def clear_span(event) - Thread.current[THREAD_KEY]&.delete(event.request_id) - end - - def tracer - Mongo::Instrumentation.instance.tracer - end - end - end - end -end diff --git a/instrumentation/mongo/lib/opentelemetry/instrumentation/mongo/subscribers/dup/subscriber.rb b/instrumentation/mongo/lib/opentelemetry/instrumentation/mongo/subscribers/dup/subscriber.rb new file mode 100644 index 0000000000..5918330b04 --- /dev/null +++ b/instrumentation/mongo/lib/opentelemetry/instrumentation/mongo/subscribers/dup/subscriber.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require_relative '../../command_serializer' + +module OpenTelemetry + module Instrumentation + module Mongo + module Subscribers + module Dup + # Event handler class for Mongo Ruby driver (dup mode - emits both old and new semantic conventions) + class Subscriber + THREAD_KEY = :__opentelemetry_mongo_spans__ + + def started(event) + # start a trace and store it in the current thread; using the `operation_id` + # is safe since it's a unique id used to link events together. Also only one + # thread is involved in this execution so thread-local storage should be safe. Reference: + # https://github.com/mongodb/mongo-ruby-driver/blob/master/lib/mongo/monitoring.rb#L70 + # https://github.com/mongodb/mongo-ruby-driver/blob/master/lib/mongo/monitoring/publishable.rb#L38-L56 + + collection = get_collection(event.command) + + # Old conventions + attributes = { + 'db.system' => 'mongodb', + 'db.name' => event.database_name, + 'db.operation' => event.command_name, + 'net.peer.name' => event.address.host, + 'net.peer.port' => event.address.port + } + + # New stable conventions + attributes['db.system.name'] = 'mongodb' + attributes['db.namespace'] = event.database_name + attributes['db.operation.name'] = event.command_name + attributes['server.address'] = event.address.host + attributes['server.port'] = event.address.port + + config = Mongo::Instrumentation.instance.config + attributes['peer.service'] = config[:peer_service] if config[:peer_service] + omit = config[:db_statement] == :omit + obfuscate = config[:db_statement] == :obfuscate + unless omit + serialized = CommandSerializer.new(event.command, obfuscate).serialize + # Both old and new attributes + attributes['db.statement'] = serialized + attributes['db.query.text'] = serialized + end + if collection + # Both old and new attributes + attributes['db.mongodb.collection'] = collection + attributes['db.collection.name'] = collection + end + attributes.compact! + + span = tracer.start_span(span_name(collection, event.command_name), attributes: attributes, kind: :client) + set_span(event, span) + end + + def failed(event) + finish_event('failed', event) do |span| + if event.is_a?(::Mongo::Monitoring::Event::CommandFailed) + error_type = extract_error_type(event) + # New stable convention attributes + span.set_attribute('error.type', error_type) + status_code = event.failure['code'] + span.set_attribute('db.response.status_code', status_code.to_s) if status_code + span.add_event('exception', + attributes: { + 'exception.type' => 'CommandFailed', + 'exception.message' => event.message + }) + span.status = OpenTelemetry::Trace::Status.error(event.message) + end + end + end + + def succeeded(event) + finish_event('succeeded', event) + end + + private + + def finish_event(name, event) + span = get_span(event) + return unless span + + yield span if block_given? + rescue StandardError => e + OpenTelemetry.logger.debug("error when handling MongoDB '#{name}' event: #{e}") + ensure + # finish span to prevent leak and remove it from thread storage + span&.finish + clear_span(event) + end + + def span_name(collection, command_name) + return command_name unless collection + + "#{command_name} #{collection}" + end + + def get_collection(command) + collection = command.values.first + collection if collection.is_a?(String) + end + + def get_span(event) + Thread.current[THREAD_KEY]&.[](event.request_id) + end + + def set_span(event, span) + Thread.current[THREAD_KEY] ||= {} + Thread.current[THREAD_KEY][event.request_id] = span + end + + def clear_span(event) + Thread.current[THREAD_KEY]&.delete(event.request_id) + end + + def extract_error_type(event) + event.failure['codeName'] || 'CommandFailed' + end + + def tracer + Mongo::Instrumentation.instance.tracer + end + end + end + end + end + end +end diff --git a/instrumentation/mongo/lib/opentelemetry/instrumentation/mongo/subscribers/old/subscriber.rb b/instrumentation/mongo/lib/opentelemetry/instrumentation/mongo/subscribers/old/subscriber.rb new file mode 100644 index 0000000000..337f6a5952 --- /dev/null +++ b/instrumentation/mongo/lib/opentelemetry/instrumentation/mongo/subscribers/old/subscriber.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require_relative '../../command_serializer' + +module OpenTelemetry + module Instrumentation + module Mongo + module Subscribers + module Old + # Event handler class for Mongo Ruby driver (old semantic conventions) + class Subscriber + THREAD_KEY = :__opentelemetry_mongo_spans__ + + def started(event) + # start a trace and store it in the current thread; using the `operation_id` + # is safe since it's a unique id used to link events together. Also only one + # thread is involved in this execution so thread-local storage should be safe. Reference: + # https://github.com/mongodb/mongo-ruby-driver/blob/master/lib/mongo/monitoring.rb#L70 + # https://github.com/mongodb/mongo-ruby-driver/blob/master/lib/mongo/monitoring/publishable.rb#L38-L56 + + collection = get_collection(event.command) + + attributes = { + 'db.system' => 'mongodb', + 'db.name' => event.database_name, + 'db.operation' => event.command_name, + 'net.peer.name' => event.address.host, + 'net.peer.port' => event.address.port + } + + config = Mongo::Instrumentation.instance.config + attributes['peer.service'] = config[:peer_service] if config[:peer_service] + omit = config[:db_statement] == :omit + obfuscate = config[:db_statement] == :obfuscate + attributes['db.statement'] = CommandSerializer.new(event.command, obfuscate).serialize unless omit + attributes['db.mongodb.collection'] = collection if collection + attributes.compact! + + span = tracer.start_span(span_name(collection, event.command_name), attributes: attributes, kind: :client) + set_span(event, span) + end + + def failed(event) + finish_event('failed', event) do |span| + if event.is_a?(::Mongo::Monitoring::Event::CommandFailed) + span.add_event('exception', + attributes: { + 'exception.type' => 'CommandFailed', + 'exception.message' => event.message + }) + end + end + end + + def succeeded(event) + finish_event('succeeded', event) + end + + private + + def finish_event(name, event) + span = get_span(event) + return unless span + + yield span if block_given? + rescue StandardError => e + OpenTelemetry.logger.debug("error when handling MongoDB '#{name}' event: #{e}") + ensure + # finish span to prevent leak and remove it from thread storage + span&.finish + clear_span(event) + end + + def span_name(collection, command_name) + return command_name unless collection + + "#{collection}.#{command_name}" + end + + def get_collection(command) + collection = command.values.first + collection if collection.is_a?(String) + end + + def get_span(event) + Thread.current[THREAD_KEY]&.[](event.request_id) + end + + def set_span(event, span) + Thread.current[THREAD_KEY] ||= {} + Thread.current[THREAD_KEY][event.request_id] = span + end + + def clear_span(event) + Thread.current[THREAD_KEY]&.delete(event.request_id) + end + + def tracer + Mongo::Instrumentation.instance.tracer + end + end + end + end + end + end +end diff --git a/instrumentation/mongo/lib/opentelemetry/instrumentation/mongo/subscribers/stable/subscriber.rb b/instrumentation/mongo/lib/opentelemetry/instrumentation/mongo/subscribers/stable/subscriber.rb new file mode 100644 index 0000000000..d604c8a020 --- /dev/null +++ b/instrumentation/mongo/lib/opentelemetry/instrumentation/mongo/subscribers/stable/subscriber.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require_relative '../../command_serializer' + +module OpenTelemetry + module Instrumentation + module Mongo + module Subscribers + module Stable + # Event handler class for Mongo Ruby driver (stable semantic conventions) + class Subscriber + THREAD_KEY = :__opentelemetry_mongo_spans__ + + def started(event) + # start a trace and store it in the current thread; using the `operation_id` + # is safe since it's a unique id used to link events together. Also only one + # thread is involved in this execution so thread-local storage should be safe. Reference: + # https://github.com/mongodb/mongo-ruby-driver/blob/master/lib/mongo/monitoring.rb#L70 + # https://github.com/mongodb/mongo-ruby-driver/blob/master/lib/mongo/monitoring/publishable.rb#L38-L56 + + collection = get_collection(event.command) + + attributes = { + 'db.system.name' => 'mongodb', + 'db.namespace' => event.database_name, + 'db.operation.name' => event.command_name, + 'server.address' => event.address.host, + 'server.port' => event.address.port + } + + config = Mongo::Instrumentation.instance.config + omit = config[:db_statement] == :omit + obfuscate = config[:db_statement] == :obfuscate + attributes['db.query.text'] = CommandSerializer.new(event.command, obfuscate).serialize unless omit + attributes['db.collection.name'] = collection if collection + attributes.compact! + + span = tracer.start_span(span_name(collection, event.command_name), attributes: attributes, kind: :client) + set_span(event, span) + end + + def failed(event) + finish_event('failed', event) do |span| + if event.is_a?(::Mongo::Monitoring::Event::CommandFailed) + error_type = extract_error_type(event) + span.set_attribute('error.type', error_type) + status_code = event.failure['code'] + span.set_attribute('db.response.status_code', status_code.to_s) if status_code + span.add_event('exception', + attributes: { + 'exception.type' => 'CommandFailed', + 'exception.message' => event.message + }) + span.status = OpenTelemetry::Trace::Status.error(event.message) + end + end + end + + def succeeded(event) + finish_event('succeeded', event) + end + + private + + def finish_event(name, event) + span = get_span(event) + return unless span + + yield span if block_given? + rescue StandardError => e + OpenTelemetry.logger.debug("error when handling MongoDB '#{name}' event: #{e}") + ensure + # finish span to prevent leak and remove it from thread storage + span&.finish + clear_span(event) + end + + def span_name(collection, command_name) + return command_name unless collection + + "#{command_name} #{collection}" + end + + def get_collection(command) + collection = command.values.first + collection if collection.is_a?(String) + end + + def get_span(event) + Thread.current[THREAD_KEY]&.[](event.request_id) + end + + def set_span(event, span) + Thread.current[THREAD_KEY] ||= {} + Thread.current[THREAD_KEY][event.request_id] = span + end + + def clear_span(event) + Thread.current[THREAD_KEY]&.delete(event.request_id) + end + + def extract_error_type(event) + event.failure['codeName'] || 'CommandFailed' + end + + def tracer + Mongo::Instrumentation.instance.tracer + end + end + end + end + end + end +end diff --git a/instrumentation/mongo/test/opentelemetry/instrumentation/mongo/dup/subscriber_test.rb b/instrumentation/mongo/test/opentelemetry/instrumentation/mongo/dup/subscriber_test.rb new file mode 100644 index 0000000000..7cfcd47430 --- /dev/null +++ b/instrumentation/mongo/test/opentelemetry/instrumentation/mongo/dup/subscriber_test.rb @@ -0,0 +1,509 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require_relative '../../../../test_helper' +require_relative '../../../../../lib/opentelemetry/instrumentation/mongo/subscribers/dup/subscriber' + +# Tests for dup mode - emits both old and new semantic convention attributes +describe OpenTelemetry::Instrumentation::Mongo::Subscribers::Dup::Subscriber do + let(:instrumentation) { OpenTelemetry::Instrumentation::Mongo::Instrumentation.instance } + let(:exporter) { EXPORTER } + let(:spans) { exporter.finished_spans } + let(:span) { exporter.finished_spans.first } + let(:client) { TestHelper.client } + let(:collection) { :artists } + let(:config) { {} } + + before do + skip unless ENV['BUNDLE_GEMFILE']&.include?('dup') + + ENV['OTEL_SEMCONV_STABILITY_OPT_IN'] = 'database/dup' + # Clear previous instrumentation state and subscribers between test runs + instrumentation.instance_variable_set(:@installed, false) + Mongo::Monitoring::Global.subscribers['Command'] = [] + instrumentation.install(config) + exporter.reset + + TestHelper.setup_mongo + + # this is currently a noop but this will future proof the test + @orig_propagation = OpenTelemetry.propagation + propagator = OpenTelemetry::Trace::Propagation::TraceContext.text_map_propagator + OpenTelemetry.propagation = propagator + end + + after do + instrumentation.instance_variable_set(:@installed, false) + ENV.delete('OTEL_SEMCONV_STABILITY_OPT_IN') + OpenTelemetry.propagation = @orig_propagation + TestHelper.teardown_mongo + end + + module DupMongoTraceTest + it 'has basic properties for both old and new attributes' do + _(spans.size).must_equal 1 + # Old attributes + _(span.attributes['db.system']).must_equal 'mongodb' + _(span.attributes['db.name']).must_equal TestHelper.database + _(span.attributes['net.peer.name']).must_equal TestHelper.host + _(span.attributes['net.peer.port']).must_equal TestHelper.port + # New stable attributes + _(span.attributes['db.system.name']).must_equal 'mongodb' + _(span.attributes['db.namespace']).must_equal TestHelper.database + _(span.attributes['server.address']).must_equal TestHelper.host + _(span.attributes['server.port']).must_equal TestHelper.port + end + end + + describe '#insert_one operation' do + before { client[collection].insert_one(params) } + + describe 'for a basic document' do + let(:params) { { name: 'FKA Twigs' } } + + include DupMongoTraceTest + + it 'has operation-specific properties for both old and new attributes' do + _(span.name).must_equal 'insert artists' + # Old attributes + _(span.attributes['db.operation']).must_equal 'insert' + _(span.attributes['db.mongodb.collection']).must_equal 'artists' + # New stable attributes + _(span.attributes['db.operation.name']).must_equal 'insert' + _(span.attributes['db.collection.name']).must_equal 'artists' + refute(span.attributes.key?('db.statement')) + refute(span.attributes.key?('db.query.text')) + end + end + + describe 'for a document with an array' do + let(:params) { { name: 'Steve', hobbies: ['hiking', 'tennis', 'fly fishing'] } } + let(:collection) { :people } + + include DupMongoTraceTest + + it 'has operation-specific properties for both old and new attributes' do + _(span.name).must_equal 'insert people' + # Old attributes + _(span.attributes['db.operation']).must_equal 'insert' + _(span.attributes['db.mongodb.collection']).must_equal 'people' + # New stable attributes + _(span.attributes['db.operation.name']).must_equal 'insert' + _(span.attributes['db.collection.name']).must_equal 'people' + refute(span.attributes.key?('db.statement')) + refute(span.attributes.key?('db.query.text')) + end + end + end + + describe 'when peer service has been set in config' do + let(:params) { { name: 'FKA Twigs' } } + let(:config) { { peer_service: 'example:mongo' } } + + include DupMongoTraceTest + + before do + client[collection].insert_one(params) + end + + it 'includes peer.service in dup mode' do + # peer.service is kept in dup mode for old semconv compatibility + _(span.attributes['peer.service']).must_equal 'example:mongo' + end + end + + describe '#insert_many operation' do + before { client[collection].insert_many(params) } + + describe 'for documents with arrays' do + let(:params) do + [ + { name: 'Steve', hobbies: ['hiking', 'tennis', 'fly fishing'] }, + { name: 'Sally', hobbies: ['skiing', 'stamp collecting'] } + ] + end + + let(:collection) { :people } + + include DupMongoTraceTest + + it 'has operation-specific properties for both old and new attributes' do + _(span.name).must_equal 'insert people' + # Old attributes + _(span.attributes['db.operation']).must_equal 'insert' + _(span.attributes['db.mongodb.collection']).must_equal 'people' + # New stable attributes + _(span.attributes['db.operation.name']).must_equal 'insert' + _(span.attributes['db.collection.name']).must_equal 'people' + refute(span.attributes.key?('db.statement')) + refute(span.attributes.key?('db.query.text')) + end + end + end + + describe '#find_all operation' do + let(:collection) { :people } + + before do + # insert a document + client[collection].insert_one(name: 'Steve', hobbies: ['hiking', 'tennis', 'fly fishing']) + exporter.reset + + # do #find_all operation + client[collection].find.each do |document| + # => yields a BSON::Document. + end + end + + include DupMongoTraceTest + + it 'has operation-specific properties for both old and new attributes' do + _(span.name).must_equal 'find people' + # Old attributes + _(span.attributes['db.operation']).must_equal 'find' + _(span.attributes['db.mongodb.collection']).must_equal 'people' + # New stable attributes + _(span.attributes['db.operation.name']).must_equal 'find' + _(span.attributes['db.collection.name']).must_equal 'people' + refute(span.attributes.key?('db.statement')) + refute(span.attributes.key?('db.query.text')) + end + end + + describe '#find operation' do + let(:collection) { :people } + + before do + # insert a document + client[collection].insert_one(name: 'Steve', hobbies: ['hiking']) + exporter.reset + + # do #find operation + result = client[collection].find(name: 'Steve').first[:hobbies] + _(result).must_equal ['hiking'] + end + + include DupMongoTraceTest + + it 'has operation-specific properties for both old and new attributes' do + _(span.name).must_equal 'find people' + # Old attributes + _(span.attributes['db.operation']).must_equal 'find' + _(span.attributes['db.mongodb.collection']).must_equal 'people' + _(span.attributes['db.statement']).must_equal '{"filter":{"name":"?"}}' + # New stable attributes + _(span.attributes['db.operation.name']).must_equal 'find' + _(span.attributes['db.collection.name']).must_equal 'people' + _(span.attributes['db.query.text']).must_equal '{"filter":{"name":"?"}}' + end + end + + describe '#update_one operation' do + let(:collection) { :people } + + before do + # insert a document + client[collection].insert_one(name: 'Sally', hobbies: ['skiing', 'stamp collecting']) + exporter.reset + + # do #update_one operation + client[collection].update_one({ name: 'Sally' }, '$set' => { 'phone_number' => '555-555-5555' }) + end + + include DupMongoTraceTest + + it 'has operation-specific properties for both old and new attributes' do + _(span.name).must_equal 'update people' + expected_statement = '{"updates":[{"q":{"name":"?"},"u":{"$set":{"phone_number":"?"}}}]}' + # Old attributes + _(span.attributes['db.operation']).must_equal 'update' + _(span.attributes['db.mongodb.collection']).must_equal 'people' + _(span.attributes['db.statement']).must_equal expected_statement + # New stable attributes + _(span.attributes['db.operation.name']).must_equal 'update' + _(span.attributes['db.collection.name']).must_equal 'people' + _(span.attributes['db.query.text']).must_equal expected_statement + end + + it 'correctly performs operation' do + _(client[collection].find(name: 'Sally').first[:phone_number]).must_equal '555-555-5555' + end + end + + describe '#update_many operation' do + let(:collection) { :people } + let(:documents) do + [ + { name: 'Steve', hobbies: ['hiking', 'tennis', 'fly fishing'] }, + { name: 'Sally', hobbies: ['skiing', 'stamp collecting'] } + ] + end + + before do + # insert documents + client[collection].insert_many(documents) + exporter.reset + + # do #update_many operation + client[collection].update_many({}, '$set' => { 'phone_number' => '555-555-5555' }) + end + + include DupMongoTraceTest + + it 'has operation-specific properties for both old and new attributes' do + _(span.name).must_equal 'update people' + expected_statement = '{"updates":[{"u":{"$set":{"phone_number":"?"}},"multi":true}]}' + # Old attributes + _(span.attributes['db.operation']).must_equal 'update' + _(span.attributes['db.mongodb.collection']).must_equal 'people' + _(span.attributes['db.statement']).must_equal expected_statement + # New stable attributes + _(span.attributes['db.operation.name']).must_equal 'update' + _(span.attributes['db.collection.name']).must_equal 'people' + _(span.attributes['db.query.text']).must_equal expected_statement + end + + it 'correctly performs operation' do + documents.each do |d| + _(client[collection].find(name: d[:name]).first[:phone_number]).must_equal '555-555-5555' + end + end + end + + describe '#delete_one operation' do + let(:collection) { :people } + + before do + # insert a document + client[collection].insert_one(name: 'Sally', hobbies: ['skiing', 'stamp collecting']) + exporter.reset + + # do #delete_one operation + client[collection].delete_one(name: 'Sally') + end + + include DupMongoTraceTest + + it 'has operation-specific properties for both old and new attributes' do + _(span.name).must_equal 'delete people' + expected_statement = '{"deletes":[{"q":{"name":"?"}}]}' + # Old attributes + _(span.attributes['db.operation']).must_equal 'delete' + _(span.attributes['db.mongodb.collection']).must_equal 'people' + _(span.attributes['db.statement']).must_equal expected_statement + # New stable attributes + _(span.attributes['db.operation.name']).must_equal 'delete' + _(span.attributes['db.collection.name']).must_equal 'people' + _(span.attributes['db.query.text']).must_equal expected_statement + end + + it 'correctly performs operation' do + _(client[collection].find(name: 'Sally').count).must_equal 0 + end + end + + describe '#delete_many operation' do + let(:collection) { :people } + let(:documents) do + [ + { name: 'Steve', hobbies: ['hiking', 'tennis', 'fly fishing'] }, + { name: 'Sally', hobbies: ['skiing', 'stamp collecting'] } + ] + end + + before do + # insert documents + client[collection].insert_many(documents) + exporter.reset + + # do #delete_many operation + client[collection].delete_many(name: /$S*/) + end + + include DupMongoTraceTest + + it 'has operation-specific properties for both old and new attributes' do + _(span.name).must_equal 'delete people' + expected_statement = '{"deletes":[{"q":{"name":"?"}}]}' + # Old attributes + _(span.attributes['db.operation']).must_equal 'delete' + _(span.attributes['db.mongodb.collection']).must_equal 'people' + _(span.attributes['db.statement']).must_equal expected_statement + # New stable attributes + _(span.attributes['db.operation.name']).must_equal 'delete' + _(span.attributes['db.collection.name']).must_equal 'people' + _(span.attributes['db.query.text']).must_equal expected_statement + end + + it 'correctly performs operation' do + documents.each do |d| + _(client[collection].find(name: d[:name]).count).must_equal 0 + end + end + end + + describe '#drop operation' do + let(:collection) { 1 } # because drop operation doesn't have a collection + + before { client.database.drop } + + include DupMongoTraceTest + + it 'has operation-specific properties for both old and new attributes' do + _(span.name).must_equal 'dropDatabase' + # Old attributes + _(span.attributes['db.operation']).must_equal 'dropDatabase' + refute(span.attributes.key?('db.mongodb.collection')) + refute(span.attributes.key?('db.statement')) + # New stable attributes + _(span.attributes['db.operation.name']).must_equal 'dropDatabase' + refute(span.attributes.key?('db.collection.name')) + refute(span.attributes.key?('db.query.text')) + end + end + + describe 'db_statement omit option' do + let(:collection) { :people } + let(:config) { { db_statement: :omit } } + + before do + # insert a document + client[collection].insert_one(name: 'Steve', hobbies: ['hiking']) + exporter.reset + + # do #find operation + result = client[collection].find(name: 'Steve').first[:hobbies] + _(result).must_equal ['hiking'] + end + + it 'omits both db.statement and db.query.text attributes' do + _(span.name).must_equal 'find people' + # Old attributes + _(span.attributes['db.operation']).must_equal 'find' + _(span.attributes['db.mongodb.collection']).must_equal 'people' + _(span.attributes).wont_include 'db.statement' + # New stable attributes + _(span.attributes['db.operation.name']).must_equal 'find' + _(span.attributes['db.collection.name']).must_equal 'people' + _(span.attributes).wont_include 'db.query.text' + end + end + + describe 'db_statement include option' do + let(:collection) { :people } + let(:config) { { db_statement: :include } } + + before do + # insert a document + client[collection].insert_one(name: 'Steve', hobbies: ['hiking']) + exporter.reset + + # do #find operation + result = client[collection].find(name: 'Steve').first[:hobbies] + _(result).must_equal ['hiking'] + end + + it 'includes non-obfuscated db.statement and db.query.text attributes' do + _(span.name).must_equal 'find people' + _(span.attributes['db.statement']).must_equal '{"filter":{"name":"Steve"}}' + _(span.attributes['db.query.text']).must_equal '{"filter":{"name":"Steve"}}' + end + end + + describe 'db_statement explicit obfuscate option' do + let(:collection) { :people } + let(:config) { { db_statement: :obfuscate } } + + before do + # insert a document + client[collection].insert_one(name: 'Steve', hobbies: ['hiking']) + exporter.reset + + # do #find operation + result = client[collection].find(name: 'Steve').first[:hobbies] + _(result).must_equal ['hiking'] + end + + it 'obfuscates both db.statement and db.query.text attributes' do + _(span.name).must_equal 'find people' + _(span.attributes['db.statement']).must_equal '{"filter":{"name":"?"}}' + _(span.attributes['db.query.text']).must_equal '{"filter":{"name":"?"}}' + end + end + + describe 'a failed query' do + before { client[:artists].drop } + + include DupMongoTraceTest + + it 'has operation-specific properties with error attributes from stable semconv' do + _(span.name).must_equal 'drop artists' + # Old attributes + _(span.attributes['db.operation']).must_equal 'drop' + _(span.attributes['db.mongodb.collection']).must_equal 'artists' + refute(span.attributes.key?('db.statement')) + # New stable attributes + _(span.attributes['db.operation.name']).must_equal 'drop' + _(span.attributes['db.collection.name']).must_equal 'artists' + refute(span.attributes.key?('db.query.text')) + # Stable semconv error attributes + _(span.attributes['error.type']).must_equal 'NamespaceNotFound' + _(span.attributes['db.response.status_code']).must_equal '26' + # Exception event + _(span.events.size).must_equal 1 + _(span.events[0].name).must_equal 'exception' + _(span.events[0].timestamp).must_be_kind_of Integer + _(span.events[0].attributes['exception.type']).must_equal 'CommandFailed' + _(span.events[0].attributes['exception.message']).must_equal '[26:NamespaceNotFound]: ns not found' + # Span status should be error + _(span.status.code).must_equal OpenTelemetry::Trace::Status::ERROR + end + + describe 'that triggers #failed before #started' do + let(:subscriber) { OpenTelemetry::Instrumentation::Mongo::Subscribers::Dup::Subscriber.new } + let(:failed_event) { subscriber.failed(event) } + let(:event) { instance_double(Mongo::Monitoring::Event::CommandFailed, request_id: double('request_id')) } + + it 'does not raise error even when thread is cleared' do + Thread.current[:__opentelemetry_mongo_spans__] = nil + failed_event + end + end + end + + describe 'with LDAP/SASL authentication' do + let(:client) { Mongo::Client.new(["#{TestHelper.host}:#{TestHelper.port}"], client_options) } + let(:client_options) do + { + database: TestHelper.database, + auth_mech: :plain, + user: 'plain_user', + password: 'plain_pass', + auth_source: '$external' + } + end + + describe 'which fails' do + before do + client[collection].insert_one(name: 'Steve', hobbies: ['hiking']) + rescue Mongo::Auth::Unauthorized + nil + end + + it 'produces spans for command and authentication' do + _(spans.size).must_equal 1 + _(span.name).must_equal 'saslStart' + # Both old and new attributes + _(span.attributes['db.operation']).must_equal 'saslStart' + _(span.attributes['db.operation.name']).must_equal 'saslStart' + _(span.events.size).must_equal 1 + _(span.events[0].name).must_equal 'exception' + _(span.events[0].timestamp).must_be_kind_of Integer + _(span.events[0].attributes['exception.message']).must_match(/mechanism.+PLAIN./) + end + end + end +end unless ENV['OMIT_SERVICES'] diff --git a/instrumentation/mongo/test/opentelemetry/instrumentation/mongo/subscriber_test.rb b/instrumentation/mongo/test/opentelemetry/instrumentation/mongo/old/subscriber_test.rb similarity index 91% rename from instrumentation/mongo/test/opentelemetry/instrumentation/mongo/subscriber_test.rb rename to instrumentation/mongo/test/opentelemetry/instrumentation/mongo/old/subscriber_test.rb index fe6ff241db..3f01b57a52 100644 --- a/instrumentation/mongo/test/opentelemetry/instrumentation/mongo/subscriber_test.rb +++ b/instrumentation/mongo/test/opentelemetry/instrumentation/mongo/old/subscriber_test.rb @@ -4,12 +4,11 @@ # # SPDX-License-Identifier: Apache-2.0 -require_relative '../../../test_helper' +require_relative '../../../../test_helper' +require_relative '../../../../../lib/opentelemetry/instrumentation/mongo/subscribers/old/subscriber' -# require Instrumentation so .install method is found: -require_relative '../../../../lib/opentelemetry/instrumentation/mongo/subscriber' - -describe OpenTelemetry::Instrumentation::Mongo::Subscriber do +# Tests for old semantic convention attributes (db.system, db.name, db.operation, net.peer.name, net.peer.port) +describe OpenTelemetry::Instrumentation::Mongo::Subscribers::Old::Subscriber do let(:instrumentation) { OpenTelemetry::Instrumentation::Mongo::Instrumentation.instance } let(:exporter) { EXPORTER } let(:spans) { exporter.finished_spans } @@ -19,7 +18,12 @@ let(:config) { {} } before do - # Clear previous instrumentation subscribers between test runs + skip unless ENV['BUNDLE_GEMFILE']&.include?('old') || ENV['BUNDLE_GEMFILE'].nil? + + # Ensure old semconv mode (default - no env var) + ENV.delete('OTEL_SEMCONV_STABILITY_OPT_IN') + # Clear previous instrumentation state and subscribers between test runs + instrumentation.instance_variable_set(:@installed, false) Mongo::Monitoring::Global.subscribers['Command'] = [] instrumentation.install(config) exporter.reset @@ -38,7 +42,7 @@ TestHelper.teardown_mongo end - module MongoTraceTest + module OldMongoTraceTest it 'has basic properties' do _(spans.size).must_equal 1 _(span.attributes['db.system']).must_equal 'mongodb' @@ -54,7 +58,7 @@ module MongoTraceTest describe 'for a basic document' do let(:params) { { name: 'FKA Twigs' } } - include MongoTraceTest + include OldMongoTraceTest it 'has operation-specific properties' do _(span.name).must_equal 'artists.insert' @@ -68,7 +72,7 @@ module MongoTraceTest let(:params) { { name: 'Steve', hobbies: ['hiking', 'tennis', 'fly fishing'] } } let(:collection) { :people } - include MongoTraceTest + include OldMongoTraceTest it 'has operation-specific properties' do _(span.name).must_equal 'people.insert' @@ -83,7 +87,7 @@ module MongoTraceTest let(:params) { { name: 'FKA Twigs' } } let(:config) { { peer_service: 'example:mongo' } } - include MongoTraceTest + include OldMongoTraceTest before do client[collection].insert_one(params) @@ -107,7 +111,7 @@ module MongoTraceTest let(:collection) { :people } - include MongoTraceTest + include OldMongoTraceTest it 'has operation-specific properties' do _(span.name).must_equal 'people.insert' @@ -132,7 +136,7 @@ module MongoTraceTest end end - include MongoTraceTest + include OldMongoTraceTest it 'has operation-specific properties' do _(span.name).must_equal 'people.find' @@ -155,7 +159,7 @@ module MongoTraceTest _(result).must_equal ['hiking'] end - include MongoTraceTest + include OldMongoTraceTest it 'has operation-specific properties' do _(span.name).must_equal 'people.find' @@ -177,7 +181,7 @@ module MongoTraceTest client[collection].update_one({ name: 'Sally' }, '$set' => { 'phone_number' => '555-555-5555' }) end - include MongoTraceTest + include OldMongoTraceTest it 'has operation-specific properties' do _(span.name).must_equal 'people.update' @@ -209,7 +213,7 @@ module MongoTraceTest client[collection].update_many({}, '$set' => { 'phone_number' => '555-555-5555' }) end - include MongoTraceTest + include OldMongoTraceTest it 'has operation-specific properties' do _(span.name).must_equal 'people.update' @@ -237,7 +241,7 @@ module MongoTraceTest client[collection].delete_one(name: 'Sally') end - include MongoTraceTest + include OldMongoTraceTest it 'has operation-specific properties' do _(span.name).must_equal 'people.delete' @@ -269,7 +273,7 @@ module MongoTraceTest client[collection].delete_many(name: /$S*/) end - include MongoTraceTest + include OldMongoTraceTest it 'has operation-specific properties' do _(span.name).must_equal 'people.delete' @@ -290,7 +294,7 @@ module MongoTraceTest before { client.database.drop } - include MongoTraceTest + include OldMongoTraceTest it 'has operation-specific properties' do _(span.name).must_equal 'dropDatabase' @@ -336,7 +340,7 @@ module MongoTraceTest _(result).must_equal ['hiking'] end - it 'obfuscates db.statement attribute' do + it 'includes non-obfuscated db.statement attribute' do _(span.name).must_equal 'people.find' _(span.attributes['db.operation']).must_equal 'find' _(span.attributes['db.mongodb.collection']).must_equal 'people' @@ -369,7 +373,7 @@ module MongoTraceTest describe 'a failed query' do before { client[:artists].drop } - include MongoTraceTest + include OldMongoTraceTest it 'has operation-specific properties' do _(span.name).must_equal 'artists.drop' @@ -384,7 +388,7 @@ module MongoTraceTest end describe 'that triggers #failed before #started' do - let(:subscriber) { OpenTelemetry::Instrumentation::Mongo::Subscriber.new } + let(:subscriber) { OpenTelemetry::Instrumentation::Mongo::Subscribers::Old::Subscriber.new } let(:failed_event) { subscriber.failed(event) } let(:event) { instance_double(Mongo::Monitoring::Event::CommandFailed, request_id: double('request_id')) } diff --git a/instrumentation/mongo/test/opentelemetry/instrumentation/mongo/stable/subscriber_test.rb b/instrumentation/mongo/test/opentelemetry/instrumentation/mongo/stable/subscriber_test.rb new file mode 100644 index 0000000000..00b1e04870 --- /dev/null +++ b/instrumentation/mongo/test/opentelemetry/instrumentation/mongo/stable/subscriber_test.rb @@ -0,0 +1,448 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require_relative '../../../../test_helper' +require_relative '../../../../../lib/opentelemetry/instrumentation/mongo/subscribers/stable/subscriber' + +# Tests for stable semantic convention attributes (db.system.name, db.namespace, db.operation.name, server.address, server.port) +describe OpenTelemetry::Instrumentation::Mongo::Subscribers::Stable::Subscriber do + let(:instrumentation) { OpenTelemetry::Instrumentation::Mongo::Instrumentation.instance } + let(:exporter) { EXPORTER } + let(:spans) { exporter.finished_spans } + let(:span) { exporter.finished_spans.first } + let(:client) { TestHelper.client } + let(:collection) { :artists } + let(:config) { {} } + + before do + skip unless ENV['BUNDLE_GEMFILE']&.include?('stable') + + ENV['OTEL_SEMCONV_STABILITY_OPT_IN'] = 'database' + # Clear previous instrumentation state and subscribers between test runs + instrumentation.instance_variable_set(:@installed, false) + Mongo::Monitoring::Global.subscribers['Command'] = [] + instrumentation.install(config) + exporter.reset + + TestHelper.setup_mongo + + # this is currently a noop but this will future proof the test + @orig_propagation = OpenTelemetry.propagation + propagator = OpenTelemetry::Trace::Propagation::TraceContext.text_map_propagator + OpenTelemetry.propagation = propagator + end + + after do + instrumentation.instance_variable_set(:@installed, false) + ENV.delete('OTEL_SEMCONV_STABILITY_OPT_IN') + OpenTelemetry.propagation = @orig_propagation + TestHelper.teardown_mongo + end + + module StableMongoTraceTest + it 'has basic properties' do + _(spans.size).must_equal 1 + _(span.attributes['db.system.name']).must_equal 'mongodb' + _(span.attributes['db.namespace']).must_equal TestHelper.database + _(span.attributes['server.address']).must_equal TestHelper.host + _(span.attributes['server.port']).must_equal TestHelper.port + # Old attributes should not be present + _(span.attributes).wont_include 'db.system' + _(span.attributes).wont_include 'db.name' + _(span.attributes).wont_include 'net.peer.name' + _(span.attributes).wont_include 'net.peer.port' + _(span.attributes).wont_include 'peer.service' + end + end + + describe '#insert_one operation' do + before { client[collection].insert_one(params) } + + describe 'for a basic document' do + let(:params) { { name: 'FKA Twigs' } } + + include StableMongoTraceTest + + it 'has operation-specific properties' do + _(span.name).must_equal 'insert artists' + _(span.attributes['db.operation.name']).must_equal 'insert' + _(span.attributes['db.collection.name']).must_equal 'artists' + refute(span.attributes.key?('db.query.text')) + # Old attributes should not be present + _(span.attributes).wont_include 'db.operation' + _(span.attributes).wont_include 'db.mongodb.collection' + end + end + + describe 'for a document with an array' do + let(:params) { { name: 'Steve', hobbies: ['hiking', 'tennis', 'fly fishing'] } } + let(:collection) { :people } + + include StableMongoTraceTest + + it 'has operation-specific properties' do + _(span.name).must_equal 'insert people' + _(span.attributes['db.operation.name']).must_equal 'insert' + _(span.attributes['db.collection.name']).must_equal 'people' + refute(span.attributes.key?('db.query.text')) + end + end + end + + describe 'when peer service has been set in config' do + let(:params) { { name: 'FKA Twigs' } } + let(:config) { { peer_service: 'example:mongo' } } + + before do + client[collection].insert_one(params) + end + + it 'does not include peer.service in stable mode' do + # peer.service is not part of stable semconv + _(span.attributes).wont_include 'peer.service' + end + end + + describe '#insert_many operation' do + before { client[collection].insert_many(params) } + + describe 'for documents with arrays' do + let(:params) do + [ + { name: 'Steve', hobbies: ['hiking', 'tennis', 'fly fishing'] }, + { name: 'Sally', hobbies: ['skiing', 'stamp collecting'] } + ] + end + + let(:collection) { :people } + + include StableMongoTraceTest + + it 'has operation-specific properties' do + _(span.name).must_equal 'insert people' + _(span.attributes['db.operation.name']).must_equal 'insert' + _(span.attributes['db.collection.name']).must_equal 'people' + refute(span.attributes.key?('db.query.text')) + end + end + end + + describe '#find_all operation' do + let(:collection) { :people } + + before do + # insert a document + client[collection].insert_one(name: 'Steve', hobbies: ['hiking', 'tennis', 'fly fishing']) + exporter.reset + + # do #find_all operation + client[collection].find.each do |document| + # => yields a BSON::Document. + end + end + + include StableMongoTraceTest + + it 'has operation-specific properties' do + _(span.name).must_equal 'find people' + _(span.attributes['db.operation.name']).must_equal 'find' + _(span.attributes['db.collection.name']).must_equal 'people' + refute(span.attributes.key?('db.query.text')) + end + end + + describe '#find operation' do + let(:collection) { :people } + + before do + # insert a document + client[collection].insert_one(name: 'Steve', hobbies: ['hiking']) + exporter.reset + + # do #find operation + result = client[collection].find(name: 'Steve').first[:hobbies] + _(result).must_equal ['hiking'] + end + + include StableMongoTraceTest + + it 'has operation-specific properties' do + _(span.name).must_equal 'find people' + _(span.attributes['db.operation.name']).must_equal 'find' + _(span.attributes['db.collection.name']).must_equal 'people' + _(span.attributes['db.query.text']).must_equal '{"filter":{"name":"?"}}' + # Old attribute should not be present + _(span.attributes).wont_include 'db.statement' + end + end + + describe '#update_one operation' do + let(:collection) { :people } + + before do + # insert a document + client[collection].insert_one(name: 'Sally', hobbies: ['skiing', 'stamp collecting']) + exporter.reset + + # do #update_one operation + client[collection].update_one({ name: 'Sally' }, '$set' => { 'phone_number' => '555-555-5555' }) + end + + include StableMongoTraceTest + + it 'has operation-specific properties' do + _(span.name).must_equal 'update people' + _(span.attributes['db.operation.name']).must_equal 'update' + _(span.attributes['db.collection.name']).must_equal 'people' + _(span.attributes['db.query.text']).must_equal '{"updates":[{"q":{"name":"?"},"u":{"$set":{"phone_number":"?"}}}]}' + end + + it 'correctly performs operation' do + _(client[collection].find(name: 'Sally').first[:phone_number]).must_equal '555-555-5555' + end + end + + describe '#update_many operation' do + let(:collection) { :people } + let(:documents) do + [ + { name: 'Steve', hobbies: ['hiking', 'tennis', 'fly fishing'] }, + { name: 'Sally', hobbies: ['skiing', 'stamp collecting'] } + ] + end + + before do + # insert documents + client[collection].insert_many(documents) + exporter.reset + + # do #update_many operation + client[collection].update_many({}, '$set' => { 'phone_number' => '555-555-5555' }) + end + + include StableMongoTraceTest + + it 'has operation-specific properties' do + _(span.name).must_equal 'update people' + _(span.attributes['db.operation.name']).must_equal 'update' + _(span.attributes['db.collection.name']).must_equal 'people' + _(span.attributes['db.query.text']).must_equal '{"updates":[{"u":{"$set":{"phone_number":"?"}},"multi":true}]}' + end + + it 'correctly performs operation' do + documents.each do |d| + _(client[collection].find(name: d[:name]).first[:phone_number]).must_equal '555-555-5555' + end + end + end + + describe '#delete_one operation' do + let(:collection) { :people } + + before do + # insert a document + client[collection].insert_one(name: 'Sally', hobbies: ['skiing', 'stamp collecting']) + exporter.reset + + # do #delete_one operation + client[collection].delete_one(name: 'Sally') + end + + include StableMongoTraceTest + + it 'has operation-specific properties' do + _(span.name).must_equal 'delete people' + _(span.attributes['db.operation.name']).must_equal 'delete' + _(span.attributes['db.collection.name']).must_equal 'people' + _(span.attributes['db.query.text']).must_equal '{"deletes":[{"q":{"name":"?"}}]}' + end + + it 'correctly performs operation' do + _(client[collection].find(name: 'Sally').count).must_equal 0 + end + end + + describe '#delete_many operation' do + let(:collection) { :people } + let(:documents) do + [ + { name: 'Steve', hobbies: ['hiking', 'tennis', 'fly fishing'] }, + { name: 'Sally', hobbies: ['skiing', 'stamp collecting'] } + ] + end + + before do + # insert documents + client[collection].insert_many(documents) + exporter.reset + + # do #delete_many operation + client[collection].delete_many(name: /$S*/) + end + + include StableMongoTraceTest + + it 'has operation-specific properties' do + _(span.name).must_equal 'delete people' + _(span.attributes['db.operation.name']).must_equal 'delete' + _(span.attributes['db.collection.name']).must_equal 'people' + _(span.attributes['db.query.text']).must_equal '{"deletes":[{"q":{"name":"?"}}]}' + end + + it 'correctly performs operation' do + documents.each do |d| + _(client[collection].find(name: d[:name]).count).must_equal 0 + end + end + end + + describe '#drop operation' do + let(:collection) { 1 } # because drop operation doesn't have a collection + + before { client.database.drop } + + include StableMongoTraceTest + + it 'has operation-specific properties' do + _(span.name).must_equal 'dropDatabase' + _(span.attributes['db.operation.name']).must_equal 'dropDatabase' + refute(span.attributes.key?('db.collection.name')) + refute(span.attributes.key?('db.query.text')) + end + end + + describe 'db_statement omit option' do + let(:collection) { :people } + let(:config) { { db_statement: :omit } } + + before do + # insert a document + client[collection].insert_one(name: 'Steve', hobbies: ['hiking']) + exporter.reset + + # do #find operation + result = client[collection].find(name: 'Steve').first[:hobbies] + _(result).must_equal ['hiking'] + end + + it 'omits db.query.text attribute' do + _(span.name).must_equal 'find people' + _(span.attributes['db.operation.name']).must_equal 'find' + _(span.attributes['db.collection.name']).must_equal 'people' + _(span.attributes).wont_include 'db.query.text' + end + end + + describe 'db_statement include option' do + let(:collection) { :people } + let(:config) { { db_statement: :include } } + + before do + # insert a document + client[collection].insert_one(name: 'Steve', hobbies: ['hiking']) + exporter.reset + + # do #find operation + result = client[collection].find(name: 'Steve').first[:hobbies] + _(result).must_equal ['hiking'] + end + + it 'includes non-obfuscated db.query.text attribute' do + _(span.name).must_equal 'find people' + _(span.attributes['db.operation.name']).must_equal 'find' + _(span.attributes['db.collection.name']).must_equal 'people' + _(span.attributes['db.query.text']).must_equal '{"filter":{"name":"Steve"}}' + end + end + + describe 'db_statement explicit obfuscate option' do + let(:collection) { :people } + let(:config) { { db_statement: :obfuscate } } + + before do + # insert a document + client[collection].insert_one(name: 'Steve', hobbies: ['hiking']) + exporter.reset + + # do #find operation + result = client[collection].find(name: 'Steve').first[:hobbies] + _(result).must_equal ['hiking'] + end + + it 'obfuscates db.query.text attribute' do + _(span.name).must_equal 'find people' + _(span.attributes['db.operation.name']).must_equal 'find' + _(span.attributes['db.collection.name']).must_equal 'people' + _(span.attributes['db.query.text']).must_equal '{"filter":{"name":"?"}}' + end + end + + describe 'a failed query' do + before { client[:artists].drop } + + include StableMongoTraceTest + + it 'has operation-specific properties' do + _(span.name).must_equal 'drop artists' + _(span.attributes['db.operation.name']).must_equal 'drop' + _(span.attributes['db.collection.name']).must_equal 'artists' + refute(span.attributes.key?('db.query.text')) + # Stable semconv error attributes + _(span.attributes['error.type']).must_equal 'NamespaceNotFound' + _(span.attributes['db.response.status_code']).must_equal '26' + # Exception event + _(span.events.size).must_equal 1 + _(span.events[0].name).must_equal 'exception' + _(span.events[0].timestamp).must_be_kind_of Integer + _(span.events[0].attributes['exception.type']).must_equal 'CommandFailed' + _(span.events[0].attributes['exception.message']).must_equal '[26:NamespaceNotFound]: ns not found' + # Span status should be error + _(span.status.code).must_equal OpenTelemetry::Trace::Status::ERROR + end + + describe 'that triggers #failed before #started' do + let(:subscriber) { OpenTelemetry::Instrumentation::Mongo::Subscribers::Stable::Subscriber.new } + let(:failed_event) { subscriber.failed(event) } + let(:event) { instance_double(Mongo::Monitoring::Event::CommandFailed, request_id: double('request_id')) } + + it 'does not raise error even when thread is cleared' do + Thread.current[:__opentelemetry_mongo_spans__] = nil + failed_event + end + end + end + + describe 'with LDAP/SASL authentication' do + let(:client) { Mongo::Client.new(["#{TestHelper.host}:#{TestHelper.port}"], client_options) } + let(:client_options) do + { + database: TestHelper.database, + auth_mech: :plain, + user: 'plain_user', + password: 'plain_pass', + auth_source: '$external' + } + end + + describe 'which fails' do + before do + client[collection].insert_one(name: 'Steve', hobbies: ['hiking']) + rescue Mongo::Auth::Unauthorized + nil + end + + it 'produces spans for command and authentication' do + _(spans.size).must_equal 1 + _(span.name).must_equal 'saslStart' + _(span.attributes['db.operation.name']).must_equal 'saslStart' + _(span.events.size).must_equal 1 + _(span.events[0].name).must_equal 'exception' + _(span.events[0].timestamp).must_be_kind_of Integer + _(span.events[0].attributes['exception.message']).must_match(/mechanism.+PLAIN./) + end + end + end +end unless ENV['OMIT_SERVICES'] diff --git a/instrumentation/mongo/test/opentelemetry/instrumentation/mongo_test.rb b/instrumentation/mongo/test/opentelemetry/instrumentation/mongo_test.rb index 5b4f001c33..c3a79ddcf0 100644 --- a/instrumentation/mongo/test/opentelemetry/instrumentation/mongo_test.rb +++ b/instrumentation/mongo/test/opentelemetry/instrumentation/mongo_test.rb @@ -10,7 +10,10 @@ let(:exporter) { EXPORTER } before do - # Clear previous instrumentation subscribers between test runs + # Ensure default semconv mode (old - no env var) + ENV.delete('OTEL_SEMCONV_STABILITY_OPT_IN') + # Clear previous instrumentation state and subscribers between test runs + instrumentation.instance_variable_set(:@installed, false) Mongo::Monitoring::Global.subscribers['Command'] = [] if defined?(Mongo::Monitoring::Global) instrumentation.install exporter.reset @@ -18,6 +21,7 @@ after do instrumentation.instance_variable_set(:@installed, false) + ENV.delete('OTEL_SEMCONV_STABILITY_OPT_IN') end describe 'present' do @@ -44,7 +48,17 @@ describe 'install' do it 'installs the subscriber' do - klass = OpenTelemetry::Instrumentation::Mongo::Subscriber + # Subscriber class depends on OTEL_SEMCONV_STABILITY_OPT_IN environment variable + stability_opt_in = ENV.fetch('OTEL_SEMCONV_STABILITY_OPT_IN', '') + values = stability_opt_in.split(',').map(&:strip) + + klass = if values.include?('database/dup') + OpenTelemetry::Instrumentation::Mongo::Subscribers::Dup::Subscriber + elsif values.include?('database') + OpenTelemetry::Instrumentation::Mongo::Subscribers::Stable::Subscriber + else + OpenTelemetry::Instrumentation::Mongo::Subscribers::Old::Subscriber + end subscribers = Mongo::Monitoring::Global.subscribers['Command'] _(subscribers.size).must_equal 1 diff --git a/instrumentation/mongo/test/test_helper.rb b/instrumentation/mongo/test/test_helper.rb index 4c67e38660..b84d3b79bd 100644 --- a/instrumentation/mongo/test/test_helper.rb +++ b/instrumentation/mongo/test/test_helper.rb @@ -38,6 +38,12 @@ def setup_mongo def teardown_mongo client.database.drop + reset_client + end + + def reset_client + @client&.close + @client = nil end def client