diff --git a/helpers/mysql/lib/opentelemetry/helpers/mysql.rb b/helpers/mysql/lib/opentelemetry/helpers/mysql.rb index 6e1b204f5c..07b8be81c6 100644 --- a/helpers/mysql/lib/opentelemetry/helpers/mysql.rb +++ b/helpers/mysql/lib/opentelemetry/helpers/mysql.rb @@ -2,7 +2,8 @@ # Copyright The OpenTelemetry Authors # -# SPDX-License-Identifier: Apache-2.0module OpenTelemetry +# SPDX-License-Identifier: Apache-2.0 + require 'opentelemetry-common' module OpenTelemetry @@ -66,6 +67,31 @@ def database_span_name(sql, operation, database_name, config) end || 'mysql' end + # Span naming following stable database semantic conventions. + # Per spec: {db.query.summary} -> {db.operation.name} {target} -> {target} -> {db.system.name} + # We don't have db.query.summary, so we use: + # {db.operation.name} {db.namespace} -> {db.namespace} -> mysql + # + # Note: Per spec, db.operation.name SHOULD NOT be extracted from db.query.text. + # The operation should only be used if explicitly provided by the application + # (e.g., via with_attributes). + # + # @param operation [String] The database operation (db.operation.name), if provided by the application. + # @param database_name [String] The name of the database (db.namespace). + # @return [String] The span name. + # @api private + def stable_database_span_name(operation, database_name) + if operation && database_name + "#{operation} #{database_name}" + elsif database_name + database_name + elsif operation + operation + else + 'mysql' + end + end + # @api private def extract_statement_type(sql) return unless sql diff --git a/helpers/mysql/test/helpers/mysql_test.rb b/helpers/mysql/test/helpers/mysql_test.rb index e301182c49..7352366e1e 100644 --- a/helpers/mysql/test/helpers/mysql_test.rb +++ b/helpers/mysql/test/helpers/mysql_test.rb @@ -55,6 +55,51 @@ end end + describe '.stable_database_span_name' do + let(:operation) { 'SELECT' } + let(:database_name) { 'mydb' } + let(:stable_span_name) { OpenTelemetry::Helpers::MySQL.stable_database_span_name(operation, database_name) } + + describe 'when operation and database_name are present' do + it 'returns "{operation} {database_name}"' do + assert_equal('SELECT mydb', stable_span_name) + end + end + + describe 'when only database_name is present' do + let(:operation) { nil } + + it 'returns database_name' do + assert_equal('mydb', stable_span_name) + end + end + + describe 'when only operation is present' do + let(:database_name) { nil } + + it 'returns operation' do + assert_equal('SELECT', stable_span_name) + end + end + + describe 'when both operation and database_name are nil' do + let(:operation) { nil } + let(:database_name) { nil } + + it 'returns mysql as fallback' do + assert_equal('mysql', stable_span_name) + end + end + + describe 'preserves operation case as provided' do + it 'does not normalize case' do + assert_equal('select mydb', OpenTelemetry::Helpers::MySQL.stable_database_span_name('select', 'mydb')) + assert_equal('SELECT mydb', OpenTelemetry::Helpers::MySQL.stable_database_span_name('SELECT', 'mydb')) + assert_equal('Select mydb', OpenTelemetry::Helpers::MySQL.stable_database_span_name('Select', 'mydb')) + end + end + end + describe '.db_operation_and_name' do let(:operation) { 'operation' } let(:database_name) { 'database_name' } diff --git a/instrumentation/trilogy/.rubocop.yml b/instrumentation/trilogy/.rubocop.yml index 1248a2f825..6a0260700e 100644 --- a/instrumentation/trilogy/.rubocop.yml +++ b/instrumentation/trilogy/.rubocop.yml @@ -1 +1,6 @@ inherit_from: ../../.rubocop.yml + +Metrics/ModuleLength: + Exclude: + - "lib/opentelemetry/instrumentation/trilogy/patches/stable/client.rb" + - "lib/opentelemetry/instrumentation/trilogy/patches/dup/client.rb" diff --git a/instrumentation/trilogy/Appraisals b/instrumentation/trilogy/Appraisals index 8e330defbe..039b77261f 100644 --- a/instrumentation/trilogy/Appraisals +++ b/instrumentation/trilogy/Appraisals @@ -4,10 +4,19 @@ # # SPDX-License-Identifier: Apache-2.0 -appraise 'trilogy-2.9' do - gem 'trilogy', '~> 2.9.0' -end +# 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.9' + end -appraise 'trilogy-latest' do - gem 'trilogy' + appraise "trilogy-latest-#{mode}" do + gem 'trilogy' + end end diff --git a/instrumentation/trilogy/README.md b/instrumentation/trilogy/README.md index b931e78b7b..6bbb562125 100644 --- a/instrumentation/trilogy/README.md +++ b/instrumentation/trilogy/README.md @@ -70,6 +70,22 @@ The `opentelemetry-instrumentation-trilogy` gem source is [on github][repo-githu 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 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/). + ## License The `opentelemetry-instrumentation-trilogy` gem is distributed under the Apache 2.0 license. See [LICENSE][license-github] for more information. diff --git a/instrumentation/trilogy/Rakefile b/instrumentation/trilogy/Rakefile index 1a64ba842e..e9d17cb879 100644 --- a/instrumentation/trilogy/Rakefile +++ b/instrumentation/trilogy/Rakefile @@ -11,6 +11,14 @@ require 'rubocop/rake_task' RuboCop::RakeTask.new +# Set OTEL_SEMCONV_STABILITY_OPT_IN based on appraisal name +gemfile = ENV.fetch('BUNDLE_GEMFILE', '') +if gemfile.include?('stable') + ENV['OTEL_SEMCONV_STABILITY_OPT_IN'] = 'database' +elsif gemfile.include?('dup') + ENV['OTEL_SEMCONV_STABILITY_OPT_IN'] = 'database/dup' +end + Rake::TestTask.new :test do |t| t.libs << 'test' t.libs << 'lib' diff --git a/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/instrumentation.rb b/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/instrumentation.rb index b4d7b20693..c69546ae37 100644 --- a/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/instrumentation.rb +++ b/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/instrumentation.rb @@ -30,16 +30,47 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base option :propagator, default: 'none', validate: %w[none tracecontext vitess] option :record_exception, default: true, validate: :boolean - attr_reader :propagator + attr_reader :propagator, :semconv private def require_dependencies - require_relative 'patches/client' + @semconv = determine_semconv + + case @semconv + when :old + require_relative 'patches/old/client' + when :stable + require_relative 'patches/stable/client' + when :dup + require_relative 'patches/dup/client' + end end def patch_client - ::Trilogy.prepend(Patches::Client) + case @semconv + when :old + ::Trilogy.prepend(Patches::Old::Client) + when :stable + ::Trilogy.prepend(Patches::Stable::Client) + when :dup + ::Trilogy.prepend(Patches::Dup::Client) + end + 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 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 b29dbb115c..0000000000 --- a/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/patches/client.rb +++ /dev/null @@ -1,117 +0,0 @@ -# frozen_string_literal: true - -# Copyright The OpenTelemetry Authors -# -# SPDX-License-Identifier: Apache-2.0 - -require 'opentelemetry-helpers-mysql' -require 'opentelemetry-helpers-sql-processor' - -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 - @_otel_database_name = connection_options&.dig(:database) - @_otel_base_attributes = _build_otel_base_attributes.freeze - - tracer.in_span( - 'connect', - attributes: client_attributes.merge!(OpenTelemetry::Instrumentation::Trilogy.attributes), - kind: :client, - record_exception: config[:record_exception] - ) do - super - end - end - - def ping(...) - tracer.in_span( - 'ping', - attributes: client_attributes.merge!(OpenTelemetry::Instrumentation::Trilogy.attributes), - kind: :client, - record_exception: config[:record_exception] - ) do - super - end - end - - def query(sql) - context_attributes = OpenTelemetry::Instrumentation::Trilogy.attributes - - tracer.in_span( - OpenTelemetry::Helpers::MySQL.database_span_name( - sql, - context_attributes[OpenTelemetry::SemanticConventions::Trace::DB_OPERATION], - @_otel_database_name, - config - ), - attributes: client_attributes(sql).merge!(context_attributes), - kind: :client, - record_exception: config[:record_exception] - ) 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 _build_otel_base_attributes - database_user = connection_options&.dig(:username) - - 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] = @_otel_database_name if @_otel_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 - end - - def client_attributes(sql = nil) - attributes = @_otel_base_attributes.dup - - 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::SqlProcessor.obfuscate_sql(sql, obfuscation_limit: config[:obfuscation_limit], adapter: :mysql) - when :include - attributes[::OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT] = sql - end - end - - attributes - 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..df11d9708f --- /dev/null +++ b/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/patches/dup/client.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'opentelemetry-helpers-mysql' +require 'opentelemetry-helpers-sql-processor' + +module OpenTelemetry + module Instrumentation + module Trilogy + module Patches + module Dup + # Module to prepend to Trilogy for instrumentation (emits both old and stable semantic conventions) + module Client + def initialize(options = {}) + @connection_options = options # This is normally done by Trilogy#initialize + @_otel_database_name = connection_options&.dig(:database) + @_otel_base_attributes = _build_otel_base_attributes.freeze + + tracer.in_span( + 'connect', + attributes: client_attributes.merge!(OpenTelemetry::Instrumentation::Trilogy.attributes), + kind: :client, + record_exception: config[:record_exception] + ) do |span| + super + rescue StandardError => e + set_error_attributes(span, e) + raise + end + end + + def ping(...) + tracer.in_span( + 'ping', + attributes: client_attributes.merge!(OpenTelemetry::Instrumentation::Trilogy.attributes), + kind: :client, + record_exception: config[:record_exception] + ) do |span| + super + rescue StandardError => e + set_error_attributes(span, e) + raise + end + end + + def query(sql) + context_attributes = OpenTelemetry::Instrumentation::Trilogy.attributes + + tracer.in_span( + OpenTelemetry::Helpers::MySQL.stable_database_span_name( + context_attributes[OpenTelemetry::SemanticConventions::Trace::DB_OPERATION] || context_attributes['db.operation.name'], + @_otel_database_name + ), + attributes: client_attributes(sql).merge!(context_attributes), + kind: :client, + record_exception: config[:record_exception] + ) 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 + rescue StandardError => e + set_error_attributes(span, e) + raise + end + end + + private + + def _build_otel_base_attributes + database_user = connection_options&.dig(:username) + mysql_host = connection_options&.fetch(:host, nil) || 'unknown sock' + mysql_port = connection_options&.dig(:port) + + # Include both old and stable attributes + attributes = { + # Old conventions + ::OpenTelemetry::SemanticConventions::Trace::DB_SYSTEM => 'mysql', + ::OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME => mysql_host, + # Stable conventions + 'db.system.name' => 'mysql', + 'server.address' => mysql_host + } + + attributes['server.port'] = mysql_port if mysql_port + + # Database name (old: db.name, stable: db.namespace) + if @_otel_database_name + attributes[::OpenTelemetry::SemanticConventions::Trace::DB_NAME] = @_otel_database_name + attributes['db.namespace'] = @_otel_database_name + end + + # db.user (old only - removed in stable) + attributes[::OpenTelemetry::SemanticConventions::Trace::DB_USER] = database_user if database_user + + # peer.service (same in both) + attributes[::OpenTelemetry::SemanticConventions::Trace::PEER_SERVICE] = config[:peer_service] unless config[:peer_service].nil? + attributes + end + + def client_attributes(sql = nil) + attributes = @_otel_base_attributes.dup + + attributes['db.instance.id'] = @connected_host unless @connected_host.nil? + + if sql + case config[:db_statement] + when :obfuscate + obfuscated = OpenTelemetry::Helpers::SqlProcessor.obfuscate_sql(sql, obfuscation_limit: config[:obfuscation_limit], adapter: :mysql) + # Old convention + attributes[::OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT] = obfuscated + # Stable convention + attributes['db.query.text'] = obfuscated + when :include + # Old convention + attributes[::OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT] = sql + # Stable convention + attributes['db.query.text'] = sql + end + end + + attributes + end + + def set_error_attributes(span, error) + span.set_attribute('error.type', error.class.name) + span.set_attribute('db.response.status_code', error.error_code.to_s) if error.error_code + 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..f95bf8aa25 --- /dev/null +++ b/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/patches/old/client.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'opentelemetry-helpers-mysql' +require 'opentelemetry-helpers-sql-processor' + +module OpenTelemetry + module Instrumentation + module Trilogy + module Patches + module Old + # Module to prepend to Trilogy for instrumentation (old semantic conventions) + module Client + def initialize(options = {}) + @connection_options = options # This is normally done by Trilogy#initialize + @_otel_database_name = connection_options&.dig(:database) + @_otel_base_attributes = _build_otel_base_attributes.freeze + + tracer.in_span( + 'connect', + attributes: client_attributes.merge!(OpenTelemetry::Instrumentation::Trilogy.attributes), + kind: :client, + record_exception: config[:record_exception] + ) do + super + end + end + + def ping(...) + tracer.in_span( + 'ping', + attributes: client_attributes.merge!(OpenTelemetry::Instrumentation::Trilogy.attributes), + kind: :client, + record_exception: config[:record_exception] + ) do + super + end + end + + def query(sql) + context_attributes = OpenTelemetry::Instrumentation::Trilogy.attributes + + tracer.in_span( + OpenTelemetry::Helpers::MySQL.database_span_name( + sql, + context_attributes[OpenTelemetry::SemanticConventions::Trace::DB_OPERATION], + @_otel_database_name, + config + ), + attributes: client_attributes(sql).merge!(context_attributes), + kind: :client, + record_exception: config[:record_exception] + ) 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 _build_otel_base_attributes + database_user = connection_options&.dig(:username) + + 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] = @_otel_database_name if @_otel_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 + end + + def client_attributes(sql = nil) + attributes = @_otel_base_attributes.dup + + 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::SqlProcessor.obfuscate_sql(sql, obfuscation_limit: config[:obfuscation_limit], adapter: :mysql) + when :include + attributes[::OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT] = sql + end + end + + attributes + 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..eb08427042 --- /dev/null +++ b/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/patches/stable/client.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'opentelemetry-helpers-mysql' +require 'opentelemetry-helpers-sql-processor' + +module OpenTelemetry + module Instrumentation + module Trilogy + module Patches + module Stable + # Module to prepend to Trilogy for instrumentation (stable semantic conventions) + module Client + def initialize(options = {}) + @connection_options = options # This is normally done by Trilogy#initialize + @_otel_database_name = connection_options&.dig(:database) + @_otel_base_attributes = _build_otel_base_attributes.freeze + + tracer.in_span( + 'connect', + attributes: client_attributes.merge!(OpenTelemetry::Instrumentation::Trilogy.attributes), + kind: :client, + record_exception: config[:record_exception] + ) do |span| + super + rescue StandardError => e + set_error_attributes(span, e) + raise + end + end + + def ping(...) + tracer.in_span( + 'ping', + attributes: client_attributes.merge!(OpenTelemetry::Instrumentation::Trilogy.attributes), + kind: :client, + record_exception: config[:record_exception] + ) do |span| + super + rescue StandardError => e + set_error_attributes(span, e) + raise + end + end + + def query(sql) + context_attributes = OpenTelemetry::Instrumentation::Trilogy.attributes + + tracer.in_span( + OpenTelemetry::Helpers::MySQL.stable_database_span_name( + context_attributes['db.operation.name'], + @_otel_database_name + ), + attributes: client_attributes(sql).merge!(context_attributes), + kind: :client, + record_exception: config[:record_exception] + ) 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 + rescue StandardError => e + set_error_attributes(span, e) + raise + end + end + + private + + def _build_otel_base_attributes + mysql_host = connection_options&.fetch(:host, nil) || 'unknown sock' + mysql_port = connection_options&.dig(:port) + + attributes = { + 'db.system.name' => 'mysql', + 'server.address' => mysql_host + } + + attributes['server.port'] = mysql_port if mysql_port + + attributes['db.namespace'] = @_otel_database_name if @_otel_database_name + attributes[::OpenTelemetry::SemanticConventions::Trace::PEER_SERVICE] = config[:peer_service] unless config[:peer_service].nil? + attributes + end + + def client_attributes(sql = nil) + attributes = @_otel_base_attributes.dup + + if sql + case config[:db_statement] + when :obfuscate + attributes['db.query.text'] = + OpenTelemetry::Helpers::SqlProcessor.obfuscate_sql(sql, obfuscation_limit: config[:obfuscation_limit], adapter: :mysql) + when :include + attributes['db.query.text'] = sql + end + end + + attributes + end + + def set_error_attributes(span, error) + span.set_attribute('error.type', error.class.name) + span.set_attribute('db.response.status_code', error.error_code.to_s) if error.error_code + 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/patches/dup/client_attributes_test.rb b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/dup/client_attributes_test.rb new file mode 100644 index 0000000000..0f6b28123d --- /dev/null +++ b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/dup/client_attributes_test.rb @@ -0,0 +1,222 @@ +# 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' + +# Unit tests for the dup semantic conventions client_attributes. +# We use Trilogy.allocate + manual ivar setup to test attribute building in isolation. +describe OpenTelemetry::Instrumentation::Trilogy::Patches::Dup::Client do + # Helper to build a test client without a real MySQL connection. + # Mirrors what initialize does for attribute setup. + def build_test_client(options) + c = Trilogy.allocate + c.instance_variable_set(:@connection_options, options) + c.instance_variable_set(:@_otel_database_name, options[:database]) + c.instance_variable_set(:@_otel_base_attributes, c.send(:_build_otel_base_attributes).freeze) + c + end + + let(:instrumentation) { OpenTelemetry::Instrumentation::Trilogy::Instrumentation.instance } + let(:exporter) { EXPORTER } + + let(:connection_options) do + { + host: 'db-primary.example.com', + port: 3307, + database: 'myapp_production', + username: 'app_user' + } + end + + let(:client) { build_test_client(connection_options) } + + before do + skip unless ENV['BUNDLE_GEMFILE']&.include?('dup') + + exporter.reset + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install({ + db_statement: :omit, + span_name: :statement_type, + propagator: 'none', + record_exception: true, + obfuscation_limit: 2000, + peer_service: nil + }) + end + + after do + instrumentation.instance_variable_set(:@installed, false) + end + + describe '#client_attributes' do + # Old attributes + it 'includes db.system (old) as mysql' do + attrs = client.send(:client_attributes) + assert_equal 'mysql', attrs[OpenTelemetry::SemanticConventions::Trace::DB_SYSTEM] + end + + it 'includes net.peer.name (old) from host option' do + attrs = client.send(:client_attributes) + assert_equal 'db-primary.example.com', attrs[OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME] + end + + it 'includes db.name (old) from database option' do + attrs = client.send(:client_attributes) + assert_equal 'myapp_production', attrs[OpenTelemetry::SemanticConventions::Trace::DB_NAME] + end + + it 'includes db.user (old) from username option' do + attrs = client.send(:client_attributes) + assert_equal 'app_user', attrs[OpenTelemetry::SemanticConventions::Trace::DB_USER] + end + + # Stable attributes + it 'includes db.system.name (stable) as mysql' do + attrs = client.send(:client_attributes) + assert_equal 'mysql', attrs['db.system.name'] + end + + it 'includes server.address (stable) from host option' do + attrs = client.send(:client_attributes) + assert_equal 'db-primary.example.com', attrs['server.address'] + end + + it 'includes server.port (stable) from port option' do + attrs = client.send(:client_attributes) + assert_equal 3307, attrs['server.port'] + end + + it 'includes db.namespace (stable) from database option' do + attrs = client.send(:client_attributes) + assert_equal 'myapp_production', attrs['db.namespace'] + end + + # Fallbacks + it 'falls back to unknown sock when host is nil for both attributes' do + c = build_test_client({ database: 'test' }) + attrs = c.send(:client_attributes) + assert_equal 'unknown sock', attrs[OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME] + assert_equal 'unknown sock', attrs['server.address'] + end + + it 'omits db.name and db.namespace when database is nil' do + c = build_test_client({ host: 'h' }) + attrs = c.send(:client_attributes) + refute attrs.key?(OpenTelemetry::SemanticConventions::Trace::DB_NAME) + refute attrs.key?('db.namespace') + end + + it 'omits db.user when username is nil' do + c = build_test_client({ host: 'h' }) + attrs = c.send(:client_attributes) + refute attrs.key?(OpenTelemetry::SemanticConventions::Trace::DB_USER) + end + + it 'includes db.instance.id when connected_host is set' do + client.instance_variable_set(:@connected_host, 'replica-3.internal') + attrs = client.send(:client_attributes) + assert_equal 'replica-3.internal', attrs['db.instance.id'] + end + + it 'omits db.instance.id when connected_host is nil' do + attrs = client.send(:client_attributes) + refute attrs.key?('db.instance.id') + end + + it 'includes peer_service when configured' do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install({ + db_statement: :omit, + span_name: :statement_type, + propagator: 'none', + record_exception: true, + obfuscation_limit: 2000, + peer_service: 'mysql-primary' + }) + attrs = client.send(:client_attributes) + assert_equal 'mysql-primary', attrs[OpenTelemetry::SemanticConventions::Trace::PEER_SERVICE] + end + + it 'returns independent hash instances on each call' do + a = client.send(:client_attributes) + b = client.send(:client_attributes) + refute_same a, b + a['extra'] = 'value' + refute b.key?('extra') + end + + describe 'with sql and db_statement config' do + before do + instrumentation.instance_variable_set(:@installed, false) + end + + it 'includes SQL in db.statement (old) when db_statement is :include' do + instrumentation.install({ + db_statement: :include, + span_name: :statement_type, + propagator: 'none', + record_exception: true, + obfuscation_limit: 2000, + peer_service: nil + }) + attrs = client.send(:client_attributes, 'SELECT * FROM users') + assert_equal 'SELECT * FROM users', attrs[OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT] + end + + it 'includes SQL in db.query.text (stable) when db_statement is :include' do + instrumentation.install({ + db_statement: :include, + span_name: :statement_type, + propagator: 'none', + record_exception: true, + obfuscation_limit: 2000, + peer_service: nil + }) + attrs = client.send(:client_attributes, 'SELECT * FROM users') + assert_equal 'SELECT * FROM users', attrs['db.query.text'] + end + + it 'omits both db.statement and db.query.text when db_statement is :omit' do + instrumentation.install({ + db_statement: :omit, + span_name: :statement_type, + propagator: 'none', + record_exception: true, + obfuscation_limit: 2000, + peer_service: nil + }) + attrs = client.send(:client_attributes, 'SELECT * FROM users') + refute attrs.key?(OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT) + refute attrs.key?('db.query.text') + end + + it 'obfuscates SQL in both attributes when db_statement is :obfuscate' do + instrumentation.install({ + db_statement: :obfuscate, + span_name: :statement_type, + propagator: 'none', + record_exception: true, + obfuscation_limit: 2000, + peer_service: nil + }) + attrs = client.send(:client_attributes, 'SELECT * FROM users WHERE id = 1') + + old_stmt = attrs[OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT] + new_stmt = attrs['db.query.text'] + + assert old_stmt, 'expected db.statement to be present' + assert new_stmt, 'expected db.query.text to be present' + refute_includes old_stmt, '1' + refute_includes new_stmt, '1' + assert_equal old_stmt, new_stmt + end + end + end +end diff --git a/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/dup/instrumentation_test.rb b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/dup/instrumentation_test.rb new file mode 100644 index 0000000000..c62b7ac2f4 --- /dev/null +++ b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/dup/instrumentation_test.rb @@ -0,0 +1,713 @@ +# 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 (dup semconv)' 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') + + 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' } } + + 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' + end + end + + describe 'with default options' do + it 'obfuscates sql in both old and stable attributes' do + client.query('SELECT 1') + + # Per stable semconv spec, span name uses db.namespace (not extracted from SQL) + _(span.name).must_equal database + # Old attribute + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT]).must_equal 'SELECT ?' + # Stable attribute + _(span.attributes['db.query.text']).must_equal 'SELECT ?' + end + + it 'includes both old and stable database connection information' do + client.query('SELECT 1') + + _(span.name).must_equal database + + # Old attributes + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_SYSTEM]).must_equal 'mysql' + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME]).must_equal(host) + _(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_STATEMENT]).must_equal 'SELECT ?' + _(span.attributes['db.instance.id']).must_be_nil + + # Stable attributes + _(span.attributes['db.system.name']).must_equal 'mysql' + _(span.attributes['server.address']).must_equal(host) + _(span.attributes['server.port']).must_equal(port) + _(span.attributes['db.namespace']).must_equal(database) + _(span.attributes['db.query.text']).must_equal 'SELECT ?' + end + + it 'uses db.namespace as span name per stable semconv spec' do + explain_sql = 'EXPLAIN SELECT 1' + client.query(explain_sql) + + # Per stable semconv spec, span name is NOT extracted from SQL + _(span.name).must_equal database + + # Old attributes + _(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 ?' + + # Stable attributes + _(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' + + # Old attributes + _(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 ?' + + # Stable attributes + _(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 both old and stable database attributes' do + _(client.connected_host).wont_be_nil + + _(span.name).must_equal 'connect' + + # Old attributes + _(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.instance.id']).must_be_nil + + # Stable attributes + _(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 both old and stable database attributes' do + _(client.connected_host).wont_be_nil + + client.ping + + _(span.name).must_equal 'ping' + + # Old attributes + _(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) + + # Stable attributes + _(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 quering for the connected host' do + it 'spans will include both old and stable attributes' do + _(client.connected_host).wont_be_nil + + _(span.name).must_equal database + + # Old attributes + _(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.instance.id']).must_be_nil + + # Stable attributes + _(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 database + + # Old attributes on last span + _(last_span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_NAME]).must_equal(database) + _(last_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 + + # Stable attributes on last span + _(last_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 both old and stable attributes' do + skip 'requires setup of a mysql host using uds connections' + _(client.connected_host).wont_be_nil + + _(span.name).must_equal database + + # Old attributes + _(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/) + + # Stable attributes + _(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 database + + # Old attributes + _(last_span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_NAME]).must_equal(database) + _(last_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 + + # Stable attributes + _(last_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 database + + # Old attributes + _(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' + + # Stable attributes + _(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 + + it 'sets error.type to the exception class name' do + expect do + client.query('SELECT INVALID') + end.must_raise Trilogy::Error + + _(span.attributes['error.type']).must_equal 'Trilogy::ProtocolError' + end + + it 'sets db.response.status_code when error has error_code' do + expect do + client.query('SELECT INVALID') + end.must_raise Trilogy::Error + + # 1054 is MySQL's "Unknown column" error code + _(span.attributes['db.response.status_code']).must_equal '1054' + end + + describe 'when record_exception is false' do + let(:config) { { record_exception: false } } + + it 'does not record exception when record_exception is false' do + expect do + client.query('SELECT INVALID') + end.must_raise Trilogy::Error + + _(span.events).must_be_nil + end + end + end + + describe 'when db_statement is set to include' do + let(:config) { { db_statement: :include } } + + it 'includes the db query statement in both attributes' 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 database + _(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 both attributes' 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 database + _(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 both attributes' 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 database + _(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 propagator is set to tracecontext' do + let(:config) { { propagator: 'tracecontext' } } + + it 'injects context on frozen strings' do + sql = 'SELECT * from users where users.id = 1 and users.email = "test@test.com"' + _(sql).must_be :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] + _(args[0]).must_be :frozen? + m.call(args[0]) + end + + allow(propagator).to receive(:inject).and_wrap_original do |m, *args| + arg_cache[:inject_input] = args[0] + _(args[0]).wont_be :frozen? + _(args[0]).must_match(sql) + 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 + # The tracecontext propagator injects traceparent and tracestate headers as SQL comments + _(arg_cache[:inject_input]).must_match(%r{/\*traceparent='00-#{span.hex_trace_id}-#{span.hex_span_id}-01'\*/}) + + # arg_cache[:inject_input] is now frozen + _(arg_cache[:inject_input]).must_be :frozen? + end + + it 'injects 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"' + _(sql).wont_be :frozen? + + expect do + client.query(sql) + end.must_raise Trilogy::Error + + # The tracecontext propagator injects traceparent header as SQL comment + _(sql).must_match(%r{/\*traceparent='00-#{span.hex_trace_id}-#{span.hex_span_id}-01'\*/}) + _(sql).wont_be :frozen? + end + end + + describe 'when db_statement is set to omit' do + let(:config) { { db_statement: :omit } } + + it 'does not include SQL statement in either 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 database + _(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 both db.statement and db.query.text attributes' 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.attributes['db.system.name']).must_equal 'mysql' + _(span.name).must_equal database + _(span.attributes[OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT]).must_be_nil + _(span.attributes['db.query.text']).must_be_nil + end + end + end + + describe 'when db_statement set as obfuscate' do + it 'obfuscates SQL parameters in both attributes' 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.attributes['db.system.name']).must_equal 'mysql' + _(span.name).must_equal database + _(span.attributes['db.statement']).must_equal obfuscated_sql + _(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 both attributes' 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.attributes['db.system.name']).must_equal 'mysql' + _(span.name).must_equal database + _(span.attributes['db.statement']).must_equal obfuscated_sql + _(span.attributes['db.query.text']).must_equal obfuscated_sql + end + end + end + end + + # In dup semconv mode, span naming follows the stable spec regardless of span_name config: + # {db.operation.name} {db.namespace} -> {db.namespace} -> mysql + # The span_name config option is ignored for dup semconv (uses stable naming). + + describe 'span naming follows stable semconv spec' do + it 'uses db.namespace as span name by default' 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 config is ignored in dup semconv + _(span.name).must_equal database + end + + it 'uses db.operation.name and db.namespace when operation is provided via with_attributes' do + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + OpenTelemetry::Instrumentation::Trilogy.with_attributes('db.operation' => 'SELECT') do + expect do + client.query(sql) + end.must_raise Trilogy::Error + end + + _(span.name).must_equal "SELECT #{database}" + end + + describe 'when db name is nil' do + let(:database) { nil } + + it 'uses db.operation.name when provided via with_attributes' do + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + OpenTelemetry::Instrumentation::Trilogy.with_attributes('db.operation' => 'SELECT') do + expect do + client.query(sql) + end.must_raise Trilogy::Error + end + + _(span.name).must_equal 'SELECT' + end + + it 'falls back to mysql when no operation or db name' 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 'mysql' + end + end + end + end +end diff --git a/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/client_attributes_test.rb b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/old/client_attributes_test.rb similarity index 89% rename from instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/client_attributes_test.rb rename to instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/old/client_attributes_test.rb index 3f9546f6b7..3bf7195c92 100644 --- a/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/client_attributes_test.rb +++ b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/old/client_attributes_test.rb @@ -6,23 +6,22 @@ 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' # Unit tests for the client_attributes hot path that do not require # a MySQL connection. We use Trilogy.allocate + manual ivar setup # to test attribute building in isolation. -# Helper to build a test client without a real MySQL connection. -# Mirrors what initialize does for attribute setup. -def build_test_client(options) - c = Trilogy.allocate - c.instance_variable_set(:@connection_options, options) - c.instance_variable_set(:@_otel_database_name, options[:database]) - c.instance_variable_set(:@_otel_base_attributes, c.send(:_build_otel_base_attributes).freeze) - c -end - -describe OpenTelemetry::Instrumentation::Trilogy::Patches::Client do +describe OpenTelemetry::Instrumentation::Trilogy::Patches::Old::Client do + # Helper to build a test client without a real MySQL connection. + # Mirrors what initialize does for attribute setup. + def build_test_client(options) + c = Trilogy.allocate + c.instance_variable_set(:@connection_options, options) + c.instance_variable_set(:@_otel_database_name, options[:database]) + c.instance_variable_set(:@_otel_base_attributes, c.send(:_build_otel_base_attributes).freeze) + c + end let(:instrumentation) { OpenTelemetry::Instrumentation::Trilogy::Instrumentation.instance } let(:exporter) { EXPORTER } @@ -37,6 +36,8 @@ def build_test_client(options) let(:client) { build_test_client(connection_options) } before do + skip unless ENV['BUNDLE_GEMFILE'].include?('old') + exporter.reset instrumentation.instance_variable_set(:@installed, false) instrumentation.install({ diff --git a/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/instrumentation_test.rb b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/old/instrumentation_test.rb similarity index 98% rename from instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/instrumentation_test.rb rename to instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/old/instrumentation_test.rb index 8290018e0e..c3ea3bfff0 100644 --- a/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/instrumentation_test.rb +++ b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/old/instrumentation_test.rb @@ -6,10 +6,10 @@ 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 +describe 'OpenTelemetry::Instrumentation::Trilogy (old semconv)' do let(:instrumentation) { OpenTelemetry::Instrumentation::Trilogy::Instrumentation.instance } let(:exporter) { EXPORTER } let(:span) { exporter.finished_spans[1] } @@ -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/patches/stable/client_attributes_test.rb b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/stable/client_attributes_test.rb new file mode 100644 index 0000000000..245b49d5d2 --- /dev/null +++ b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/stable/client_attributes_test.rb @@ -0,0 +1,201 @@ +# 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' + +# Unit tests for the stable semantic conventions client_attributes. +# We use Trilogy.allocate + manual ivar setup to test attribute building in isolation. +describe OpenTelemetry::Instrumentation::Trilogy::Patches::Stable::Client do + # Helper to build a test client without a real MySQL connection. + # Mirrors what initialize does for attribute setup. + def build_test_client(options) + c = Trilogy.allocate + c.instance_variable_set(:@connection_options, options) + c.instance_variable_set(:@_otel_database_name, options[:database]) + c.instance_variable_set(:@_otel_base_attributes, c.send(:_build_otel_base_attributes).freeze) + c + end + + let(:instrumentation) { OpenTelemetry::Instrumentation::Trilogy::Instrumentation.instance } + let(:exporter) { EXPORTER } + + let(:connection_options) do + { + host: 'db-primary.example.com', + port: 3307, + database: 'myapp_production', + username: 'app_user' + } + end + + let(:client) { build_test_client(connection_options) } + + before do + skip unless ENV['BUNDLE_GEMFILE']&.include?('stable') + + exporter.reset + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install({ + db_statement: :omit, + span_name: :statement_type, + propagator: 'none', + record_exception: true, + obfuscation_limit: 2000, + peer_service: nil + }) + end + + after do + instrumentation.instance_variable_set(:@installed, false) + end + + describe '#client_attributes' do + it 'includes db.system.name as mysql' do + attrs = client.send(:client_attributes) + assert_equal 'mysql', attrs['db.system.name'] + end + + it 'includes server.address from host option' do + attrs = client.send(:client_attributes) + assert_equal 'db-primary.example.com', attrs['server.address'] + end + + it 'includes server.port from port option' do + attrs = client.send(:client_attributes) + assert_equal 3307, attrs['server.port'] + end + + it 'includes db.namespace from database option' do + attrs = client.send(:client_attributes) + assert_equal 'myapp_production', attrs['db.namespace'] + end + + it 'falls back to unknown sock when host is nil' do + c = build_test_client({ database: 'test' }) + attrs = c.send(:client_attributes) + assert_equal 'unknown sock', attrs['server.address'] + end + + it 'omits db.namespace when database is nil' do + c = build_test_client({ host: 'h' }) + attrs = c.send(:client_attributes) + refute attrs.key?('db.namespace') + end + + it 'does not include db.user (removed in stable)' do + attrs = client.send(:client_attributes) + refute attrs.key?(OpenTelemetry::SemanticConventions::Trace::DB_USER) + end + + it 'does not include db.instance.id (removed in stable)' do + client.instance_variable_set(:@connected_host, 'replica-3.internal') + attrs = client.send(:client_attributes) + refute attrs.key?('db.instance.id') + end + + it 'includes peer_service when configured' do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install({ + db_statement: :omit, + span_name: :statement_type, + propagator: 'none', + record_exception: true, + obfuscation_limit: 2000, + peer_service: 'mysql-primary' + }) + attrs = client.send(:client_attributes) + assert_equal 'mysql-primary', attrs[OpenTelemetry::SemanticConventions::Trace::PEER_SERVICE] + end + + it 'returns independent hash instances on each call' do + a = client.send(:client_attributes) + b = client.send(:client_attributes) + refute_same a, b + a['extra'] = 'value' + refute b.key?('extra') + end + + describe 'does not include old attributes' do + it 'does not include db.system' do + attrs = client.send(:client_attributes) + refute attrs.key?(OpenTelemetry::SemanticConventions::Trace::DB_SYSTEM) + end + + it 'does not include net.peer.name' do + attrs = client.send(:client_attributes) + refute attrs.key?(OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME) + end + + it 'does not include db.name' do + attrs = client.send(:client_attributes) + refute attrs.key?(OpenTelemetry::SemanticConventions::Trace::DB_NAME) + end + end + + describe 'with sql and db_statement config' do + before do + instrumentation.instance_variable_set(:@installed, false) + end + + it 'includes SQL as db.query.text when db_statement is :include' do + instrumentation.install({ + db_statement: :include, + span_name: :statement_type, + propagator: 'none', + record_exception: true, + obfuscation_limit: 2000, + peer_service: nil + }) + attrs = client.send(:client_attributes, 'SELECT * FROM users') + assert_equal 'SELECT * FROM users', attrs['db.query.text'] + end + + it 'does not include db.statement when db_statement is :include' do + instrumentation.install({ + db_statement: :include, + span_name: :statement_type, + propagator: 'none', + record_exception: true, + obfuscation_limit: 2000, + peer_service: nil + }) + attrs = client.send(:client_attributes, 'SELECT * FROM users') + refute attrs.key?(OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT) + end + + it 'omits db.query.text when db_statement is :omit' do + instrumentation.install({ + db_statement: :omit, + span_name: :statement_type, + propagator: 'none', + record_exception: true, + obfuscation_limit: 2000, + peer_service: nil + }) + attrs = client.send(:client_attributes, 'SELECT * FROM users') + refute attrs.key?('db.query.text') + end + + it 'obfuscates SQL in db.query.text when db_statement is :obfuscate' do + instrumentation.install({ + db_statement: :obfuscate, + span_name: :statement_type, + propagator: 'none', + record_exception: true, + obfuscation_limit: 2000, + peer_service: nil + }) + attrs = client.send(:client_attributes, 'SELECT * FROM users WHERE id = 1') + stmt = attrs['db.query.text'] + assert stmt, 'expected db.query.text to be present' + refute_includes stmt, '1' + end + end + end +end diff --git a/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/stable/instrumentation_test.rb b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/stable/instrumentation_test.rb new file mode 100644 index 0000000000..c3680a73a6 --- /dev/null +++ b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/stable/instrumentation_test.rb @@ -0,0 +1,619 @@ +# 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 (stable semconv)' 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') + + 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') + + # Per stable semconv spec, span name uses db.namespace (not extracted from SQL) + _(span.name).must_equal database + _(span.attributes['db.query.text']).must_equal 'SELECT ?' + end + + it 'includes database connection information' do + client.query('SELECT 1') + + _(span.name).must_equal database + _(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['server.port']).must_equal(port) + end + + it 'does not include old attribute names' do + client.query('SELECT 1') + + _(span.attributes.key?('db.system')).must_equal false + _(span.attributes.key?('net.peer.name')).must_equal false + _(span.attributes.key?('db.name')).must_equal false + _(span.attributes.key?('db.statement')).must_equal false + _(span.attributes.key?('db.user')).must_equal false + end + + it 'uses db.namespace as span name per stable semconv spec' do + explain_sql = 'EXPLAIN SELECT 1' + client.query(explain_sql) + + # Per stable semconv spec, span name is NOT extracted from SQL + _(span.name).must_equal database + _(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 db.system.name as span.name fallback when db.namespace is not available' do + expect do + client.query('DESELECT 1') + end.must_raise Trilogy::Error + + _(span.name).must_equal database + _(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' + _(span.attributes['server.address']).must_equal(host) + end + end + + describe 'when quering for the connected host' do + it 'spans will include the server.address attribute' do + _(client.connected_host).wont_be_nil + + _(span.name).must_equal database + _(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 database + _(last_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 server.address attribute' do + skip 'requires setup of a mysql host using uds connections' + _(client.connected_host).wont_be_nil + + _(span.name).must_equal database + _(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 database + _(last_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 database + _(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 + + it 'sets error.type to the exception class name' do + expect do + client.query('SELECT INVALID') + end.must_raise Trilogy::Error + + _(span.attributes['error.type']).must_equal 'Trilogy::ProtocolError' + end + + it 'sets db.response.status_code when error has error_code' do + expect do + client.query('SELECT INVALID') + end.must_raise Trilogy::Error + + # 1054 is MySQL's "Unknown column" error code + _(span.attributes['db.response.status_code']).must_equal '1054' + end + + describe 'when record_exception is false' do + let(:config) { { record_exception: false } } + + it 'does not record exception when record_exception is false' do + expect do + client.query('SELECT INVALID') + end.must_raise Trilogy::Error + + _(span.events).must_be_nil + end + 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 database + _(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 database + _(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 database + _(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 propagator is set to tracecontext' do + let(:config) { { propagator: 'tracecontext' } } + + it 'injects context on frozen strings' do + sql = 'SELECT * from users where users.id = 1 and users.email = "test@test.com"' + _(sql).must_be :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] + _(args[0]).must_be :frozen? + m.call(args[0]) + end + + allow(propagator).to receive(:inject).and_wrap_original do |m, *args| + arg_cache[:inject_input] = args[0] + _(args[0]).wont_be :frozen? + _(args[0]).must_match(sql) + 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 + # The tracecontext propagator injects traceparent and tracestate headers as SQL comments + _(arg_cache[:inject_input]).must_match(%r{/\*traceparent='00-#{span.hex_trace_id}-#{span.hex_span_id}-01'\*/}) + + # arg_cache[:inject_input] is now frozen + _(arg_cache[:inject_input]).must_be :frozen? + end + + it 'injects 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"' + _(sql).wont_be :frozen? + + expect do + client.query(sql) + end.must_raise Trilogy::Error + + # The tracecontext propagator injects traceparent header as SQL comment + _(sql).must_match(%r{/\*traceparent='00-#{span.hex_trace_id}-#{span.hex_span_id}-01'\*/}) + _(sql).wont_be :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 database + _(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 database + _(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 database + _(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 database + _(span.attributes['db.query.text']).must_equal obfuscated_sql + end + end + end + end + + # In stable semconv, span naming follows the spec regardless of span_name config: + # {db.operation.name} {db.namespace} -> {db.namespace} -> mysql + # The span_name config option is ignored for stable semconv. + + describe 'span naming follows stable semconv spec' do + it 'uses db.namespace as span name by default' 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 config is ignored in stable semconv + _(span.name).must_equal database + end + + it 'uses db.operation.name and db.namespace when operation is provided via with_attributes' do + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + OpenTelemetry::Instrumentation::Trilogy.with_attributes('db.operation.name' => 'SELECT') do + expect do + client.query(sql) + end.must_raise Trilogy::Error + end + + _(span.name).must_equal "SELECT #{database}" + end + + describe 'when db name is nil' do + let(:database) { nil } + + it 'uses db.operation.name when provided via with_attributes' do + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + OpenTelemetry::Instrumentation::Trilogy.with_attributes('db.operation.name' => 'SELECT') do + expect do + client.query(sql) + end.must_raise Trilogy::Error + end + + _(span.name).must_equal 'SELECT' + end + + it 'falls back to mysql when no operation or db name' 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 'mysql' + end + end + end + end +end