diff --git a/instrumentation/trilogy/Appraisals b/instrumentation/trilogy/Appraisals index 8da0a5d7f9..63062795d3 100644 --- a/instrumentation/trilogy/Appraisals +++ b/instrumentation/trilogy/Appraisals @@ -4,6 +4,15 @@ # # SPDX-License-Identifier: Apache-2.0 -appraise 'trilogy-2' do - gem 'trilogy', '~> 2.3' +# 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/ + +semconv_stability = %w[old stable dup] + +semconv_stability.each do |mode| + appraise "trilogy-2-#{mode}" do + gem 'trilogy', '~> 2.3' + end end diff --git a/instrumentation/trilogy/README.md b/instrumentation/trilogy/README.md index b931e78b7b..2e49107e29 100644 --- a/instrumentation/trilogy/README.md +++ b/instrumentation/trilogy/README.md @@ -83,3 +83,19 @@ The `opentelemetry-instrumentation-trilogy` gem is distributed under the Apache [slack-channel]: https://cloud-native.slack.com/archives/C01NWKKMKMY [discussions-url]: https://github.com/open-telemetry/opentelemetry-ruby/discussions [opentelemetry-mysql]: https://github.com/open-telemetry/opentelemetry-ruby-contrib/tree/main/instrumentation/mysql2 + +## Database semantic convention stability + +In the OpenTelemetry ecosystem, database semantic conventions have now reached a stable state. However, the initial Trilogy 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, Trilogy instrumentation code comes in three patch versions: `dup`, `old`, and `stable`. These versions are identical except for the attributes they send. Any changes to Trilogy 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/). diff --git a/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/instrumentation.rb b/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/instrumentation.rb index 684fbe6602..b656a43574 100644 --- a/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/instrumentation.rb +++ b/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/instrumentation.rb @@ -10,8 +10,9 @@ module Trilogy # The Instrumentation class contains logic to detect and install the Trilogy instrumentation class Instrumentation < OpenTelemetry::Instrumentation::Base install do |config| - require_dependencies - patch_client + patch_type = determine_semconv + send(:"require_dependencies_#{patch_type}") + send(:"patch_client_#{patch_type}") configure_propagator(config) end @@ -33,12 +34,45 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base private - def require_dependencies - require_relative 'patches/client' + def determine_semconv + stability_opt_in = ENV.fetch('OTEL_SEMCONV_STABILITY_OPT_IN', '') + values = stability_opt_in.split(',').map(&:strip) + + if values.include?('database/dup') + 'dup' + elsif values.include?('database') + 'stable' + else + 'old' + end + end + + def require_dependencies_dup + require_relative 'patches/dup/client' + end + + def require_dependencies_stable + require_relative 'patches/stable/client' + end + + def require_dependencies_old + require_relative 'patches/old/client' end def patch_client - ::Trilogy.prepend(Patches::Client) + ::Trilogy.prepend(Patches::Dup::Client) + end + + def patch_client_stable + ::Trilogy.prepend(Patches::Stable::Client) + end + + def patch_client_old + ::Trilogy.prepend(Patches::Old::Client) + end + + def patch_client_dup + ::Trilogy.prepend(Patches::Dup::Client) end def configure_propagator(config) diff --git a/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/patches/client.rb b/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/patches/client.rb deleted file mode 100644 index 8f15dd89d8..0000000000 --- a/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/patches/client.rb +++ /dev/null @@ -1,114 +0,0 @@ -# frozen_string_literal: true - -# Copyright The OpenTelemetry Authors -# -# SPDX-License-Identifier: Apache-2.0 - -require 'opentelemetry-helpers-mysql' -require 'opentelemetry-helpers-sql-obfuscation' - -module OpenTelemetry - module Instrumentation - module Trilogy - module Patches - # Module to prepend to Trilogy for instrumentation - module Client - def initialize(options = {}) - @connection_options = options # This is normally done by Trilogy#initialize - - tracer.in_span( - 'connect', - attributes: client_attributes.merge!(OpenTelemetry::Instrumentation::Trilogy.attributes), - kind: :client - ) do - super - end - end - - def ping(...) - tracer.in_span( - 'ping', - attributes: client_attributes.merge!(OpenTelemetry::Instrumentation::Trilogy.attributes), - kind: :client - ) do - super - end - end - - def query(sql) - tracer.in_span( - OpenTelemetry::Helpers::MySQL.database_span_name( - sql, - OpenTelemetry::Instrumentation::Trilogy.attributes[ - OpenTelemetry::SemanticConventions::Trace::DB_OPERATION - ], - database_name, - config - ), - attributes: client_attributes(sql).merge!( - OpenTelemetry::Instrumentation::Trilogy.attributes - ), - kind: :client - ) do |_span, context| - if propagator && sql.frozen? - sql = +sql - propagator.inject(sql, context: context) - sql.freeze - elsif propagator - propagator.inject(sql, context: context) - end - - super - end - end - - private - - def client_attributes(sql = nil) - attributes = { - ::OpenTelemetry::SemanticConventions::Trace::DB_SYSTEM => 'mysql', - ::OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME => connection_options&.fetch(:host, 'unknown sock') || 'unknown sock' - } - - attributes[::OpenTelemetry::SemanticConventions::Trace::DB_NAME] = database_name if database_name - attributes[::OpenTelemetry::SemanticConventions::Trace::DB_USER] = database_user if database_user - attributes[::OpenTelemetry::SemanticConventions::Trace::PEER_SERVICE] = config[:peer_service] unless config[:peer_service].nil? - attributes['db.instance.id'] = @connected_host unless @connected_host.nil? - - if sql - case config[:db_statement] - when :obfuscate - attributes[::OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT] = - OpenTelemetry::Helpers::SqlObfuscation.obfuscate_sql(sql, obfuscation_limit: config[:obfuscation_limit], adapter: :mysql) - when :include - attributes[::OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT] = sql - end - end - - attributes - end - - def database_name - connection_options[:database] - end - - def database_user - connection_options[:username] - end - - def tracer - Trilogy::Instrumentation.instance.tracer - end - - def config - Trilogy::Instrumentation.instance.config - end - - def propagator - Trilogy::Instrumentation.instance.propagator - end - end - end - end - end -end diff --git a/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/patches/dup/client.rb b/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/patches/dup/client.rb new file mode 100644 index 0000000000..7f11b3e82d --- /dev/null +++ b/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/patches/dup/client.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'opentelemetry-helpers-mysql' +require 'opentelemetry-helpers-sql-obfuscation' + +module OpenTelemetry + module Instrumentation + module Trilogy + module Patches + module Dup + # Module to prepend to Trilogy for instrumentation + module Client + def initialize(options = {}) + @connection_options = options # This is normally done by Trilogy#initialize + + tracer.in_span( + 'connect', + attributes: client_attributes.merge!(OpenTelemetry::Instrumentation::Trilogy.attributes), + kind: :client + ) do + super + end + end + + def ping(...) + tracer.in_span( + 'ping', + attributes: client_attributes.merge!(OpenTelemetry::Instrumentation::Trilogy.attributes), + kind: :client + ) do + super + end + end + + def query(sql) + tracer.in_span( + OpenTelemetry::Helpers::MySQL.database_span_name( + sql, + OpenTelemetry::Instrumentation::Trilogy.attributes[ + 'db.operation.name' + ], + database_name, + config + ), + attributes: client_attributes(sql).merge!( + OpenTelemetry::Instrumentation::Trilogy.attributes + ), + kind: :client + ) do |_span, context| + if propagator && sql.frozen? + sql = +sql + propagator.inject(sql, context: context) + sql.freeze + elsif propagator + propagator.inject(sql, context: context) + end + + super + end + end + + private + + def client_attributes(sql = nil) + attributes = { + ::OpenTelemetry::SemanticConventions::Trace::DB_SYSTEM => 'mysql', + ::OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME => connection_options&.fetch(:host, 'unknown sock') || 'unknown sock', + 'db.system.name' => 'mysql', + 'server.address' => connection_options&.fetch(:host, 'unknown sock') || 'unknown sock' + } + + attributes[::OpenTelemetry::SemanticConventions::Trace::DB_NAME] = database_name if database_name + attributes['db.namespace'] = database_name if database_name + attributes[::OpenTelemetry::SemanticConventions::Trace::DB_USER] = database_user if database_user + attributes[::OpenTelemetry::SemanticConventions::Trace::PEER_SERVICE] = config[:peer_service] unless config[:peer_service].nil? + attributes['db.instance.id'] = @connected_host unless @connected_host.nil? + + if sql + case config[:db_statement] + when :obfuscate + attributes[::OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT] = + OpenTelemetry::Helpers::SqlObfuscation.obfuscate_sql(sql, obfuscation_limit: config[:obfuscation_limit], adapter: :mysql) + attributes['db.query.text'] = + OpenTelemetry::Helpers::SqlObfuscation.obfuscate_sql(sql, obfuscation_limit: config[:obfuscation_limit], adapter: :mysql) + when :include + attributes[::OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT] = sql + attributes['db.query.text'] = sql + end + end + + attributes + end + + def database_name + connection_options[:database] + end + + def database_user + connection_options[:username] + end + + def tracer + Trilogy::Instrumentation.instance.tracer + end + + def config + Trilogy::Instrumentation.instance.config + end + + def propagator + Trilogy::Instrumentation.instance.propagator + end + end + end + end + end + end +end diff --git a/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/patches/old/client.rb b/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/patches/old/client.rb new file mode 100644 index 0000000000..c31b21d0e8 --- /dev/null +++ b/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/patches/old/client.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'opentelemetry-helpers-mysql' +require 'opentelemetry-helpers-sql-obfuscation' + +module OpenTelemetry + module Instrumentation + module Trilogy + module Patches + module Old + # Module to prepend to Trilogy for instrumentation + module Client + def initialize(options = {}) + @connection_options = options # This is normally done by Trilogy#initialize + + tracer.in_span( + 'connect', + attributes: client_attributes.merge!(OpenTelemetry::Instrumentation::Trilogy.attributes), + kind: :client + ) do + super + end + end + + def ping(...) + tracer.in_span( + 'ping', + attributes: client_attributes.merge!(OpenTelemetry::Instrumentation::Trilogy.attributes), + kind: :client + ) do + super + end + end + + def query(sql) + tracer.in_span( + OpenTelemetry::Helpers::MySQL.database_span_name( + sql, + OpenTelemetry::Instrumentation::Trilogy.attributes[ + OpenTelemetry::SemanticConventions::Trace::DB_OPERATION + ], + database_name, + config + ), + attributes: client_attributes(sql).merge!( + OpenTelemetry::Instrumentation::Trilogy.attributes + ), + kind: :client + ) do |_span, context| + if propagator && sql.frozen? + sql = +sql + propagator.inject(sql, context: context) + sql.freeze + elsif propagator + propagator.inject(sql, context: context) + end + + super + end + end + + private + + def client_attributes(sql = nil) + attributes = { + ::OpenTelemetry::SemanticConventions::Trace::DB_SYSTEM => 'mysql', + ::OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME => connection_options&.fetch(:host, 'unknown sock') || 'unknown sock' + } + + attributes[::OpenTelemetry::SemanticConventions::Trace::DB_NAME] = database_name if database_name + attributes[::OpenTelemetry::SemanticConventions::Trace::DB_USER] = database_user if database_user + attributes[::OpenTelemetry::SemanticConventions::Trace::PEER_SERVICE] = config[:peer_service] unless config[:peer_service].nil? + attributes['db.instance.id'] = @connected_host unless @connected_host.nil? + + if sql + case config[:db_statement] + when :obfuscate + attributes[::OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT] = + OpenTelemetry::Helpers::SqlObfuscation.obfuscate_sql(sql, obfuscation_limit: config[:obfuscation_limit], adapter: :mysql) + when :include + attributes[::OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT] = sql + end + end + + attributes + end + + def database_name + connection_options[:database] + end + + def database_user + connection_options[:username] + end + + def tracer + Trilogy::Instrumentation.instance.tracer + end + + def config + Trilogy::Instrumentation.instance.config + end + + def propagator + Trilogy::Instrumentation.instance.propagator + end + end + end + end + end + end +end diff --git a/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/patches/stable/client.rb b/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/patches/stable/client.rb new file mode 100644 index 0000000000..4d88bfcec6 --- /dev/null +++ b/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/patches/stable/client.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'opentelemetry-helpers-mysql' +require 'opentelemetry-helpers-sql-obfuscation' + +module OpenTelemetry + module Instrumentation + module Trilogy + module Patches + module Stable + # Module to prepend to Trilogy for instrumentation + module Client + def initialize(options = {}) + @connection_options = options # This is normally done by Trilogy#initialize + + tracer.in_span( + 'connect', + attributes: client_attributes.merge!(OpenTelemetry::Instrumentation::Trilogy.attributes), + kind: :client + ) do + super + end + end + + def ping(...) + tracer.in_span( + 'ping', + attributes: client_attributes.merge!(OpenTelemetry::Instrumentation::Trilogy.attributes), + kind: :client + ) do + super + end + end + + def query(sql) + tracer.in_span( + OpenTelemetry::Helpers::MySQL.database_span_name( + sql, + OpenTelemetry::Instrumentation::Trilogy.attributes[ + 'db.operation.name' + ], + database_name, + config + ), + attributes: client_attributes(sql).merge!( + OpenTelemetry::Instrumentation::Trilogy.attributes + ), + kind: :client + ) do |_span, context| + if propagator && sql.frozen? + sql = +sql + propagator.inject(sql, context: context) + sql.freeze + elsif propagator + propagator.inject(sql, context: context) + end + + super + end + end + + private + + def client_attributes(sql = nil) + attributes = { + 'db.system.name' => 'mysql', + 'server.address' => connection_options&.fetch(:host, 'unknown sock') || 'unknown sock' + } + + attributes['db.namespace'] = database_name if database_name + attributes[::OpenTelemetry::SemanticConventions::Trace::PEER_SERVICE] = config[:peer_service] unless config[:peer_service].nil? + + if sql + case config[:db_statement] + when :obfuscate + attributes['db.query.text'] = + OpenTelemetry::Helpers::SqlObfuscation.obfuscate_sql(sql, obfuscation_limit: config[:obfuscation_limit], adapter: :mysql) + when :include + attributes['db.query.text'] = sql + end + end + + attributes + end + + def database_name + connection_options[:database] + end + + def database_user + connection_options[:username] + end + + def tracer + Trilogy::Instrumentation.instance.tracer + end + + def config + Trilogy::Instrumentation.instance.config + end + + def propagator + Trilogy::Instrumentation.instance.propagator + end + end + end + end + end + end +end diff --git a/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/dup/instrumentation_test.rb b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/dup/instrumentation_test.rb new file mode 100644 index 0000000000..7a1fff0c4f --- /dev/null +++ b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/dup/instrumentation_test.rb @@ -0,0 +1,671 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +require_relative '../../../../../lib/opentelemetry/instrumentation/trilogy' +require_relative '../../../../../lib/opentelemetry/instrumentation/trilogy/patches/dup/client' + +describe OpenTelemetry::Instrumentation::Trilogy do + let(:instrumentation) { OpenTelemetry::Instrumentation::Trilogy::Instrumentation.instance } + let(:exporter) { EXPORTER } + let(:span) { exporter.finished_spans[1] } + let(:config) { {} } + let(:driver_options) do + { + host: host, + port: port, + username: username, + password: password, + database: database, + ssl: false + } + end + let(:client) do + Trilogy.new(driver_options) + end + + let(:host) { ENV.fetch('TEST_MYSQL_HOST', '127.0.0.1') } + let(:port) { ENV.fetch('TEST_MYSQL_PORT', '3306').to_i } + let(:database) { ENV.fetch('TEST_MYSQL_DB', 'mysql') } + let(:username) { ENV.fetch('TEST_MYSQL_USER', 'root') } + let(:password) { ENV.fetch('TEST_MYSQL_PASSWORD', 'root') } + + before do + skip unless ENV['BUNDLE_GEMFILE'].include?('dup') + + ENV['OTEL_SEMCONV_STABILITY_OPT_IN'] = 'database/dup' + + exporter.reset + end + + after do + # Force re-install of instrumentation + instrumentation.instance_variable_set(:@installed, false) + end + + it 'has #name' do + _(instrumentation.name).must_equal 'OpenTelemetry::Instrumentation::Trilogy' + end + + it 'has #version' do + _(instrumentation.version).wont_be_nil + _(instrumentation.version).wont_be_empty + end + + describe '#install' do + it 'accepts peer service name from config' do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install(peer_service: 'readonly:mysql') + client.query('SELECT 1') + + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::PEER_SERVICE]).must_equal 'readonly:mysql' + end + + it 'omits peer service by default' do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install({}) + client.query('SELECT 1') + + _(span.attributes.keys).wont_include(OpenTelemetry::SemanticConventions::Trace::PEER_SERVICE) + end + end + + describe '#compatible?' do + describe 'when an unsupported version is installed' do + it 'is incompatible' do + stub_const('Trilogy::VERSION', '2.2.0') + _(instrumentation.compatible?).must_equal false + + stub_const('Trilogy::VERSION', '2.3.0.beta') + _(instrumentation.compatible?).must_equal false + + stub_const('Trilogy::VERSION', '3.0.0') + _(instrumentation.compatible?).must_equal false + end + end + + describe 'when supported version is installed' do + it 'is compatible' do + stub_const('Trilogy::VERSION', '2.3.0') + _(instrumentation.compatible?).must_equal true + + stub_const('Trilogy::VERSION', '3.0.0.rc1') + _(instrumentation.compatible?).must_equal true + end + end + end + + describe 'tracing' do + before do + instrumentation.install(config) + end + + describe '.attributes' do + let(:attributes) { { 'db.statement' => 'foobar', 'db.query.text' => 'foobar' } } + + it 'returns an empty hash by default' do + _(OpenTelemetry::Instrumentation::Trilogy.attributes).must_equal({}) + end + + it 'returns the current attributes hash' do + OpenTelemetry::Instrumentation::Trilogy.with_attributes(attributes) do + _(OpenTelemetry::Instrumentation::Trilogy.attributes).must_equal(attributes) + end + end + + it 'sets span attributes according to with_attributes hash' do + OpenTelemetry::Instrumentation::Trilogy.with_attributes(attributes) do + client.query('SELECT 1') + end + + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT]).must_equal 'foobar' + _(span.attributes['db.query.text']).must_equal 'foobar' + end + end + + describe 'with default options' do + it 'obfuscates sql' do + client.query('SELECT 1') + + _(span.name).must_equal 'select' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT]).must_equal 'SELECT ?' + end + + it 'includes database connection information' do + client.query('SELECT 1') + + _(span.name).must_equal 'select' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_NAME]).must_equal(database) + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_USER]).must_equal(username) + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_SYSTEM]).must_equal 'mysql' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT]).must_equal 'SELECT ?' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME]).must_equal(host) + _(span.attributes['db.namespace']).must_equal(database) + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.attributes['db.query.text']).must_equal 'SELECT ?' + _(span.attributes['server.address']).must_equal(host) + _(span.attributes['db.instance.id']).must_be_nil + end + + it 'extracts statement type' do + explain_sql = 'EXPLAIN SELECT 1' + client.query(explain_sql) + + _(span.name).must_equal 'explain' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_NAME]).must_equal(database) + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_USER]).must_equal(username) + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_SYSTEM]).must_equal 'mysql' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT]).must_equal 'EXPLAIN SELECT ?' + _(span.attributes['db.namespace']).must_equal(database) + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.attributes['db.query.text']).must_equal 'EXPLAIN SELECT ?' + end + + it 'uses component.name and instance.name as span.name fallbacks with invalid sql' do + expect do + client.query('DESELECT 1') + end.must_raise Trilogy::Error + + _(span.name).must_equal 'mysql' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_NAME]).must_equal(database) + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_USER]).must_equal(username) + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_SYSTEM]).must_equal 'mysql' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT]).must_equal 'DESELECT ?' + _(span.attributes['db.namespace']).must_equal(database) + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.attributes['db.query.text']).must_equal 'DESELECT ?' + end + end + + describe 'when connecting' do + let(:span) { exporter.finished_spans.first } + + it 'spans will include database name' do + _(client.connected_host).wont_be_nil + + _(span.name).must_equal 'connect' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_NAME]).must_equal(database) + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_USER]).must_equal(username) + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_SYSTEM]).must_equal 'mysql' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME]).must_equal(host) + _(span.attributes['db.namespace']).must_equal(database) + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.attributes['server.address']).must_equal(host) + _(span.attributes['db.instance.id']).must_be_nil + end + end + + describe 'when pinging' do + let(:span) { exporter.finished_spans[2] } + + it 'spans will include database name' do + _(client.connected_host).wont_be_nil + + client.ping + + _(span.name).must_equal 'ping' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_NAME]).must_equal(database) + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_USER]).must_equal(username) + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_SYSTEM]).must_equal 'mysql' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME]).must_equal(host) + _(span.attributes['db.namespace']).must_equal(database) + _(span.attributes['db.system.name']).must_equal 'mysql' + end + end + + describe 'when quering for the connected host' do + it 'spans will include the net.peer.name attribute' do + _(client.connected_host).wont_be_nil + + _(span.name).must_equal 'select' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_NAME]).must_equal(database) + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_USER]).must_equal(username) + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_SYSTEM]).must_equal 'mysql' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT]).must_equal 'select @@hostname' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME]).must_equal(host) + _(span.attributes['db.namespace']).must_equal(database) + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.attributes['db.query.text']).must_equal 'select @@hostname' + _(span.attributes['server.address']).must_equal(host) + _(span.attributes['db.instance.id']).must_be_nil + + client.query('SELECT 1') + + last_span = exporter.finished_spans.last + + _(last_span.name).must_equal 'select' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_NAME]).must_equal(database) + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_USER]).must_equal(username) + _(last_span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_SYSTEM]).must_equal 'mysql' + _(last_span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT]).must_equal 'SELECT ?' + _(last_span.attributes[OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME]).must_equal(host) + _(last_span.attributes['db.instance.id']).must_equal client.connected_host + _(span.attributes['db.namespace']).must_equal(database) + _(last_span.attributes['db.system.name']).must_equal 'mysql' + _(last_span.attributes['db.query.text']).must_equal 'SELECT ?' + _(last_span.attributes['server.address']).must_equal(host) + end + end + + describe 'when quering using unix domain socket' do + let(:client) do + Trilogy.new( + username: username, + password: password, + ssl: false + ) + end + + it 'spans will include the net.peer.name attribute' do + skip 'requires setup of a mysql host using uds connections' + _(client.connected_host).wont_be_nil + + _(span.name).must_equal 'select' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_NAME]).must_equal(database) + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_USER]).must_equal(username) + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_SYSTEM]).must_equal 'mysql' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT]).must_equal 'select @@hostname' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME]).must_match(/sock/) + _(span.attributes['db.namespace']).must_equal(database) + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.attributes['db.query.text']).must_equal 'select @@hostname' + _(span.attributes['server.address']).must_match(/sock/) + + client.query('SELECT 1') + + last_span = exporter.finished_spans.last + + _(last_span.name).must_equal 'select' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_NAME]).must_equal(database) + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_USER]).must_equal(username) + _(last_span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_SYSTEM]).must_equal 'mysql' + _(last_span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT]).must_equal 'SELECT ?' + _(last_span.attributes[OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME]).wont_equal(/sock/) + _(last_span.attributes[OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME]).must_equal client.connected_host + _(span.attributes['db.namespace']).must_equal(database) + _(last_span.attributes['db.system.name']).must_equal 'mysql' + _(last_span.attributes['db.query.text']).must_equal 'SELECT ?' + _(last_span.attributes['server.address']).wont_equal(/sock/) + _(last_span.attributes['server.address']).must_equal client.connected_host + end + end + + describe 'when queries fail' do + it 'sets span status to error' do + expect do + client.query('SELECT INVALID') + end.must_raise Trilogy::Error + + _(span.name).must_equal 'select' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_NAME]).must_equal(database) + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_USER]).must_equal(username) + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_SYSTEM]).must_equal 'mysql' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT]).must_equal 'SELECT INVALID' + _(span.attributes['db.namespace']).must_equal(database) + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.attributes['db.query.text']).must_equal 'SELECT INVALID' + + _(span.status.code).must_equal( + OpenTelemetry::Trace::Status::ERROR + ) + _(span.events.first.name).must_equal 'exception' + _(span.events.first.attributes['exception.type']).must_match(/Trilogy.*Error/) + _(span.events.first.attributes['exception.message']).wont_be_nil + _(span.events.first.attributes['exception.stacktrace']).wont_be_nil + end + end + + describe 'when db_statement is set to include' do + let(:config) { { db_statement: :include } } + + it 'includes the db query statement' do + sql = 'SELECT * from users where users.id = 1 and users.email = "test@test.com"' + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'select' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT]).must_equal sql + _(span.attributes['db.query.text']).must_equal sql + end + end + + describe 'when db_statement is set to obfuscate' do + let(:config) { { db_statement: :obfuscate } } + + it 'obfuscates SQL parameters in db.statement' do + sql = 'SELECT * from users where users.id = 1 and users.email = "test@test.com"' + obfuscated_sql = 'SELECT * from users where users.id = ? and users.email = ?' + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'select' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT]).must_equal obfuscated_sql + _(span.attributes['db.query.text']).must_equal obfuscated_sql + end + + it 'encodes invalid byte sequences for db.statement' do + # \255 is off-limits https://en.wikipedia.org/wiki/UTF-8#Codepage_layout + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com\255'" + obfuscated_sql = 'SELECT * from users where users.id = ? and users.email = ?' + + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'select' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT]).must_equal obfuscated_sql + _(span.attributes['db.query.text']).must_equal obfuscated_sql + end + + describe 'with obfuscation_limit' do + let(:config) { { db_statement: :obfuscate, obfuscation_limit: 10 } } + + it 'returns a message when the limit is reached' do + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + obfuscated_sql = 'SQL not obfuscated, query exceeds 10 characters' + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.attributes['db.statement']).must_equal obfuscated_sql + _(span.attributes['db.query.text']).must_equal obfuscated_sql + end + end + end + + describe 'when propagator is set to none' do + let(:config) { { propagator: :none } } + + it 'does not inject context' do + sql = +'SELECT * from users where users.id = 1 and users.email = "test@test.com"' + original_sql = sql.dup + expect do + client.query(sql) + end.must_raise Trilogy::Error + _(sql).must_equal original_sql + end + end + + describe 'when propagator is set to nil' do + let(:config) { { propagator: nil } } + + it 'does not inject context' do + sql = +'SELECT * from users where users.id = 1 and users.email = "test@test.com"' + original_sql = sql.dup + expect do + client.query(sql) + end.must_raise Trilogy::Error + _(sql).must_equal original_sql + end + end + + describe 'when propagator is set to vitess' do + let(:config) { { propagator: 'vitess' } } + + it 'does inject context on frozen strings' do + sql = 'SELECT * from users where users.id = 1 and users.email = "test@test.com"' + assert(sql.frozen?) + propagator = OpenTelemetry::Instrumentation::Trilogy::Instrumentation.instance.propagator + + arg_cache = {} # maintain handles to args + allow(client).to receive(:query).and_wrap_original do |m, *args| + arg_cache[:query_input] = args[0] + assert(args[0].frozen?) + m.call(args[0]) + end + + allow(propagator).to receive(:inject).and_wrap_original do |m, *args| + arg_cache[:inject_input] = args[0] + refute(args[0].frozen?) + assert_match(sql, args[0]) + m.call(args[0], context: args[1][:context]) + end + + expect do + client.query(sql) + end.must_raise Trilogy::Error + + # arg_cache[:inject_input] _was_ a mutable string, so it has the context injected + encoded = Base64.strict_encode64("{\"uber-trace-id\":\"#{span.hex_trace_id}:#{span.hex_span_id}:0:1\"}") + assert_equal(arg_cache[:inject_input], "/*VT_SPAN_CONTEXT=#{encoded}*/#{sql}") + + # arg_cache[:inject_input] is now frozen + assert(arg_cache[:inject_input].frozen?) + end + + it 'does inject context on unfrozen strings' do + # inbound SQL is not frozen (string prefixed with +) + sql = +'SELECT * from users where users.id = 1 and users.email = "test@test.com"' + refute(sql.frozen?) + + # dup sql for comparison purposes, since propagator mutates it + cached_sql = sql.dup + + expect do + client.query(sql) + end.must_raise Trilogy::Error + + encoded = Base64.strict_encode64("{\"uber-trace-id\":\"#{span.hex_trace_id}:#{span.hex_span_id}:0:1\"}") + assert_equal(sql, "/*VT_SPAN_CONTEXT=#{encoded}*/#{cached_sql}") + refute(sql.frozen?) + end + end + + describe 'when db_statement is set to omit' do + let(:config) { { db_statement: :omit } } + + it 'does not include SQL statement as db.statement attribute' do + sql = 'SELECT * from users where users.id = 1 and users.email = "test@test.com"' + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'select' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT]).must_be_nil + _(span.attributes['db.query.text']).must_be_nil + end + end + + describe 'when db_statement is configured via environment variable' do + describe 'when db_statement set as omit' do + it 'omits db.statement attribute' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'db_statement=omit;') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.attributes['db.system']).must_equal 'mysql' + _(span.name).must_equal 'select' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT]).must_be_nil + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.name).must_equal 'select' + _(span.attributes['db.query.text']).must_be_nil + end + end + end + + describe 'when db_statement set as obfuscate' do + it 'obfuscates SQL parameters in db.statement' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'db_statement=obfuscate;') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + obfuscated_sql = 'SELECT * from users where users.id = ? and users.email = ?' + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.attributes['db.system']).must_equal 'mysql' + _(span.name).must_equal 'select' + _(span.attributes['db.statement']).must_equal obfuscated_sql + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.attributes['db.query.text']).must_equal obfuscated_sql + end + end + end + + describe 'when db_statement is set differently than local config' do + let(:config) { { db_statement: :omit } } + + it 'overrides local config and obfuscates SQL parameters in db.statement' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'db_statement=obfuscate') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + obfuscated_sql = 'SELECT * from users where users.id = ? and users.email = ?' + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.attributes['db.system']).must_equal 'mysql' + _(span.name).must_equal 'select' + _(span.attributes['db.statement']).must_equal obfuscated_sql + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.attributes['db.query.text']).must_equal obfuscated_sql + end + end + end + end + + describe 'when span_name is set as statement_type' do + it 'sets span name to statement type' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'span_name=statement_type') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'select' + end + end + + it 'sets span name to mysql when statement type is not recognized' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'span_name=statement_type') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = 'DESELECT 1' + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'mysql' + end + end + end + + describe 'when span_name is set as db_name' do + it 'sets span name to db name' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'span_name=db_name') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'mysql' + end + end + + describe 'when db name is nil' do + let(:database) { nil } + + it 'sets span name to mysql' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'span_name=db_name') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'mysql' + end + end + end + end + + describe 'when span_name is set as db_operation_and_name' do + it 'sets span name to db operation and name' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'span_name=db_operation_and_name') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + OpenTelemetry::Instrumentation::Trilogy.with_attributes('db.operation' => 'foo', 'db.operation.name' => 'foo') do + expect do + client.query(sql) + end.must_raise Trilogy::Error + end + + _(span.name).must_equal 'foo mysql' + end + end + + it 'sets span name to db name when db.operation is not set' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'span_name=db_operation_and_name') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'mysql' + end + end + + describe 'when db name is nil' do + let(:database) { nil } + + it 'sets span name to db operation' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'span_name=db_operation_and_name') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + OpenTelemetry::Instrumentation::Trilogy.with_attributes('db.operation' => 'foo', 'db.operation.name' => 'foo') do + expect do + client.query(sql) + end.must_raise Trilogy::Error + end + + _(span.name).must_equal 'foo' + end + end + + it 'sets span name to mysql when db.operation is not set' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'span_name=db_operation_and_name') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'mysql' + end + end + end + end + end +end diff --git a/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/instrumentation_test.rb b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/old/instrumentation_test.rb similarity index 99% rename from instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/instrumentation_test.rb rename to instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/old/instrumentation_test.rb index 350ee5f834..9e7f733c21 100644 --- a/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/instrumentation_test.rb +++ b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/old/instrumentation_test.rb @@ -6,8 +6,8 @@ require 'test_helper' -require_relative '../../../../lib/opentelemetry/instrumentation/trilogy' -require_relative '../../../../lib/opentelemetry/instrumentation/trilogy/patches/client' +require_relative '../../../../../lib/opentelemetry/instrumentation/trilogy' +require_relative '../../../../../lib/opentelemetry/instrumentation/trilogy/patches/old/client' describe OpenTelemetry::Instrumentation::Trilogy do let(:instrumentation) { OpenTelemetry::Instrumentation::Trilogy::Instrumentation.instance } @@ -35,6 +35,8 @@ let(:password) { ENV.fetch('TEST_MYSQL_PASSWORD', 'root') } before do + skip unless ENV['BUNDLE_GEMFILE'].include?('old') + exporter.reset end diff --git a/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/stable/instrumentation_test.rb b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/stable/instrumentation_test.rb new file mode 100644 index 0000000000..b370734a3d --- /dev/null +++ b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/stable/instrumentation_test.rb @@ -0,0 +1,608 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +require_relative '../../../../../lib/opentelemetry/instrumentation/trilogy' +require_relative '../../../../../lib/opentelemetry/instrumentation/trilogy/patches/stable/client' + +describe OpenTelemetry::Instrumentation::Trilogy do + let(:instrumentation) { OpenTelemetry::Instrumentation::Trilogy::Instrumentation.instance } + let(:exporter) { EXPORTER } + let(:span) { exporter.finished_spans[1] } + let(:config) { {} } + let(:driver_options) do + { + host: host, + port: port, + username: username, + password: password, + database: database, + ssl: false + } + end + let(:client) do + Trilogy.new(driver_options) + end + + let(:host) { ENV.fetch('TEST_MYSQL_HOST', '127.0.0.1') } + let(:port) { ENV.fetch('TEST_MYSQL_PORT', '3306').to_i } + let(:database) { ENV.fetch('TEST_MYSQL_DB', 'mysql') } + let(:username) { ENV.fetch('TEST_MYSQL_USER', 'root') } + let(:password) { ENV.fetch('TEST_MYSQL_PASSWORD', 'root') } + + before do + skip unless ENV['BUNDLE_GEMFILE'].include?('stable') + + ENV['OTEL_SEMCONV_STABILITY_OPT_IN'] = 'database' + + exporter.reset + end + + after do + # Force re-install of instrumentation + instrumentation.instance_variable_set(:@installed, false) + end + + it 'has #name' do + _(instrumentation.name).must_equal 'OpenTelemetry::Instrumentation::Trilogy' + end + + it 'has #version' do + _(instrumentation.version).wont_be_nil + _(instrumentation.version).wont_be_empty + end + + describe '#install' do + it 'accepts peer service name from config' do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install(peer_service: 'readonly:mysql') + client.query('SELECT 1') + + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::PEER_SERVICE]).must_equal 'readonly:mysql' + end + + it 'omits peer service by default' do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install({}) + client.query('SELECT 1') + + _(span.attributes.keys).wont_include(OpenTelemetry::SemanticConventions::Trace::PEER_SERVICE) + end + end + + describe '#compatible?' do + describe 'when an unsupported version is installed' do + it 'is incompatible' do + stub_const('Trilogy::VERSION', '2.2.0') + _(instrumentation.compatible?).must_equal false + + stub_const('Trilogy::VERSION', '2.3.0.beta') + _(instrumentation.compatible?).must_equal false + + stub_const('Trilogy::VERSION', '3.0.0') + _(instrumentation.compatible?).must_equal false + end + end + + describe 'when supported version is installed' do + it 'is compatible' do + stub_const('Trilogy::VERSION', '2.3.0') + _(instrumentation.compatible?).must_equal true + + stub_const('Trilogy::VERSION', '3.0.0.rc1') + _(instrumentation.compatible?).must_equal true + end + end + end + + describe 'tracing' do + before do + instrumentation.install(config) + end + + describe '.attributes' do + let(:attributes) { { 'db.query.text' => 'foobar' } } + + it 'returns an empty hash by default' do + _(OpenTelemetry::Instrumentation::Trilogy.attributes).must_equal({}) + end + + it 'returns the current attributes hash' do + OpenTelemetry::Instrumentation::Trilogy.with_attributes(attributes) do + _(OpenTelemetry::Instrumentation::Trilogy.attributes).must_equal(attributes) + end + end + + it 'sets span attributes according to with_attributes hash' do + OpenTelemetry::Instrumentation::Trilogy.with_attributes(attributes) do + client.query('SELECT 1') + end + + _(span.attributes['db.query.text']).must_equal 'foobar' + end + end + + describe 'with default options' do + it 'obfuscates sql' do + client.query('SELECT 1') + + _(span.name).must_equal 'select' + _(span.attributes['db.query.text']).must_equal 'SELECT ?' + end + + it 'includes database connection information' do + client.query('SELECT 1') + + _(span.name).must_equal 'select' + _(span.attributes['db.namespace']).must_equal(database) + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.attributes['db.query.text']).must_equal 'SELECT ?' + _(span.attributes['server.address']).must_equal(host) + end + + it 'extracts statement type' do + explain_sql = 'EXPLAIN SELECT 1' + client.query(explain_sql) + + _(span.name).must_equal 'explain' + _(span.attributes['db.namespace']).must_equal(database) + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.attributes['db.query.text']).must_equal 'EXPLAIN SELECT ?' + end + + it 'uses component.name and instance.name as span.name fallbacks with invalid sql' do + expect do + client.query('DESELECT 1') + end.must_raise Trilogy::Error + + _(span.name).must_equal 'mysql' + _(span.attributes['db.namespace']).must_equal(database) + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.attributes['db.query.text']).must_equal 'DESELECT ?' + end + end + + describe 'when connecting' do + let(:span) { exporter.finished_spans.first } + + it 'spans will include database name' do + _(client.connected_host).wont_be_nil + + _(span.name).must_equal 'connect' + _(span.attributes['db.namespace']).must_equal(database) + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.attributes['server.address']).must_equal(host) + end + end + + describe 'when pinging' do + let(:span) { exporter.finished_spans[2] } + + it 'spans will include database name' do + _(client.connected_host).wont_be_nil + + client.ping + + _(span.name).must_equal 'ping' + _(span.attributes['db.namespace']).must_equal(database) + _(span.attributes['db.system.name']).must_equal 'mysql' + end + end + + describe 'when quering for the connected host' do + it 'spans will include the net.peer.name attribute' do + _(client.connected_host).wont_be_nil + + _(span.name).must_equal 'select' + _(span.attributes['db.namespace']).must_equal(database) + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.attributes['db.query.text']).must_equal 'select @@hostname' + _(span.attributes['server.address']).must_equal(host) + + client.query('SELECT 1') + + last_span = exporter.finished_spans.last + + _(last_span.name).must_equal 'select' + _(span.attributes['db.namespace']).must_equal(database) + _(last_span.attributes['db.system.name']).must_equal 'mysql' + _(last_span.attributes['db.query.text']).must_equal 'SELECT ?' + _(last_span.attributes['server.address']).must_equal(host) + end + end + + describe 'when quering using unix domain socket' do + let(:client) do + Trilogy.new( + username: username, + password: password, + ssl: false + ) + end + + it 'spans will include the net.peer.name attribute' do + skip 'requires setup of a mysql host using uds connections' + _(client.connected_host).wont_be_nil + + _(span.name).must_equal 'select' + _(span.attributes['db.namespace']).must_equal(database) + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.attributes['db.query.text']).must_equal 'select @@hostname' + _(span.attributes['server.address']).must_match(/sock/) + + client.query('SELECT 1') + + last_span = exporter.finished_spans.last + + _(last_span.name).must_equal 'select' + _(span.attributes['db.namespace']).must_equal(database) + _(last_span.attributes['db.system.name']).must_equal 'mysql' + _(last_span.attributes['db.query.text']).must_equal 'SELECT ?' + _(last_span.attributes['server.address']).wont_equal(/sock/) + _(last_span.attributes['server.address']).must_equal client.connected_host + end + end + + describe 'when queries fail' do + it 'sets span status to error' do + expect do + client.query('SELECT INVALID') + end.must_raise Trilogy::Error + + _(span.name).must_equal 'select' + _(span.attributes['db.namespace']).must_equal(database) + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.attributes['db.query.text']).must_equal 'SELECT INVALID' + + _(span.status.code).must_equal( + OpenTelemetry::Trace::Status::ERROR + ) + _(span.events.first.name).must_equal 'exception' + _(span.events.first.attributes['exception.type']).must_match(/Trilogy.*Error/) + _(span.events.first.attributes['exception.message']).wont_be_nil + _(span.events.first.attributes['exception.stacktrace']).wont_be_nil + end + end + + describe 'when db_statement is set to include' do + let(:config) { { db_statement: :include } } + + it 'includes the db query statement' do + sql = 'SELECT * from users where users.id = 1 and users.email = "test@test.com"' + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'select' + _(span.attributes['db.query.text']).must_equal sql + end + end + + describe 'when db_statement is set to obfuscate' do + let(:config) { { db_statement: :obfuscate } } + + it 'obfuscates SQL parameters in db.query.text' do + sql = 'SELECT * from users where users.id = 1 and users.email = "test@test.com"' + obfuscated_sql = 'SELECT * from users where users.id = ? and users.email = ?' + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'select' + _(span.attributes['db.query.text']).must_equal obfuscated_sql + end + + it 'encodes invalid byte sequences for db.query.text' do + # \255 is off-limits https://en.wikipedia.org/wiki/UTF-8#Codepage_layout + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com\255'" + obfuscated_sql = 'SELECT * from users where users.id = ? and users.email = ?' + + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'select' + _(span.attributes['db.query.text']).must_equal obfuscated_sql + end + + describe 'with obfuscation_limit' do + let(:config) { { db_statement: :obfuscate, obfuscation_limit: 10 } } + + it 'returns a message when the limit is reached' do + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + obfuscated_sql = 'SQL not obfuscated, query exceeds 10 characters' + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.attributes['db.query.text']).must_equal obfuscated_sql + end + end + end + + describe 'when propagator is set to none' do + let(:config) { { propagator: :none } } + + it 'does not inject context' do + sql = +'SELECT * from users where users.id = 1 and users.email = "test@test.com"' + original_sql = sql.dup + expect do + client.query(sql) + end.must_raise Trilogy::Error + _(sql).must_equal original_sql + end + end + + describe 'when propagator is set to nil' do + let(:config) { { propagator: nil } } + + it 'does not inject context' do + sql = +'SELECT * from users where users.id = 1 and users.email = "test@test.com"' + original_sql = sql.dup + expect do + client.query(sql) + end.must_raise Trilogy::Error + _(sql).must_equal original_sql + end + end + + describe 'when propagator is set to vitess' do + let(:config) { { propagator: 'vitess' } } + + it 'does inject context on frozen strings' do + sql = 'SELECT * from users where users.id = 1 and users.email = "test@test.com"' + assert(sql.frozen?) + propagator = OpenTelemetry::Instrumentation::Trilogy::Instrumentation.instance.propagator + + arg_cache = {} # maintain handles to args + allow(client).to receive(:query).and_wrap_original do |m, *args| + arg_cache[:query_input] = args[0] + assert(args[0].frozen?) + m.call(args[0]) + end + + allow(propagator).to receive(:inject).and_wrap_original do |m, *args| + arg_cache[:inject_input] = args[0] + refute(args[0].frozen?) + assert_match(sql, args[0]) + m.call(args[0], context: args[1][:context]) + end + + expect do + client.query(sql) + end.must_raise Trilogy::Error + + # arg_cache[:inject_input] _was_ a mutable string, so it has the context injected + encoded = Base64.strict_encode64("{\"uber-trace-id\":\"#{span.hex_trace_id}:#{span.hex_span_id}:0:1\"}") + assert_equal(arg_cache[:inject_input], "/*VT_SPAN_CONTEXT=#{encoded}*/#{sql}") + + # arg_cache[:inject_input] is now frozen + assert(arg_cache[:inject_input].frozen?) + end + + it 'does inject context on unfrozen strings' do + # inbound SQL is not frozen (string prefixed with +) + sql = +'SELECT * from users where users.id = 1 and users.email = "test@test.com"' + refute(sql.frozen?) + + # dup sql for comparison purposes, since propagator mutates it + cached_sql = sql.dup + + expect do + client.query(sql) + end.must_raise Trilogy::Error + + encoded = Base64.strict_encode64("{\"uber-trace-id\":\"#{span.hex_trace_id}:#{span.hex_span_id}:0:1\"}") + assert_equal(sql, "/*VT_SPAN_CONTEXT=#{encoded}*/#{cached_sql}") + refute(sql.frozen?) + end + end + + describe 'when db_statement is set to omit' do + let(:config) { { db_statement: :omit } } + + it 'does not include SQL statement as db.query.text attribute' do + sql = 'SELECT * from users where users.id = 1 and users.email = "test@test.com"' + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'select' + _(span.attributes['db.query.text']).must_be_nil + end + end + + describe 'when db_statement is configured via environment variable' do + describe 'when db_statement set as omit' do + it 'omits db.query.text attribute' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'db_statement=omit;') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.name).must_equal 'select' + _(span.attributes['db.query.text']).must_be_nil + end + end + end + + describe 'when db_statement set as obfuscate' do + it 'obfuscates SQL parameters in db.query.text' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'db_statement=obfuscate;') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + obfuscated_sql = 'SELECT * from users where users.id = ? and users.email = ?' + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.name).must_equal 'select' + _(span.attributes['db.query.text']).must_equal obfuscated_sql + end + end + end + + describe 'when db_statement is set differently than local config' do + let(:config) { { db_statement: :omit } } + + it 'overrides local config and obfuscates SQL parameters in db.query.text' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'db_statement=obfuscate') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + obfuscated_sql = 'SELECT * from users where users.id = ? and users.email = ?' + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.name).must_equal 'select' + _(span.attributes['db.query.text']).must_equal obfuscated_sql + end + end + end + end + + describe 'when span_name is set as statement_type' do + it 'sets span name to statement type' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'span_name=statement_type') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'select' + end + end + + it 'sets span name to mysql when statement type is not recognized' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'span_name=statement_type') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = 'DESELECT 1' + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'mysql' + end + end + end + + describe 'when span_name is set as db_name' do + it 'sets span name to db name' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'span_name=db_name') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'mysql' + end + end + + describe 'when db name is nil' do + let(:database) { nil } + + it 'sets span name to mysql' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'span_name=db_name') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'mysql' + end + end + end + end + + describe 'when span_name is set as db_operation_and_name' do + it 'sets span name to db operation and name' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'span_name=db_operation_and_name') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + OpenTelemetry::Instrumentation::Trilogy.with_attributes('db.operation.name' => 'foo') do + expect do + client.query(sql) + end.must_raise Trilogy::Error + end + + _(span.name).must_equal 'foo mysql' + end + end + + it 'sets span name to db name when db.operation.name is not set' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'span_name=db_operation_and_name') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'mysql' + end + end + + describe 'when db name is nil' do + let(:database) { nil } + + it 'sets span name to db operation' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'span_name=db_operation_and_name') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + OpenTelemetry::Instrumentation::Trilogy.with_attributes('db.operation.name' => 'foo') do + expect do + client.query(sql) + end.must_raise Trilogy::Error + end + + _(span.name).must_equal 'foo' + end + end + + it 'sets span name to mysql when db.operation.name is not set' do + OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_TRILOGY_CONFIG_OPTS' => 'span_name=db_operation_and_name') do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install + + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + expect do + client.query(sql) + end.must_raise Trilogy::Error + + _(span.name).must_equal 'mysql' + end + end + end + end + end +end