diff --git a/helpers/mysql/lib/opentelemetry/helpers/mysql.rb b/helpers/mysql/lib/opentelemetry/helpers/mysql.rb index 6e1b204f5c..7bad09f7a3 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 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/.simplecov b/instrumentation/trilogy/.simplecov new file mode 100644 index 0000000000..20df6c23dc --- /dev/null +++ b/instrumentation/trilogy/.simplecov @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'digest' + +digest = Digest::MD5.new +digest.update('test') +digest.update(ENV.fetch('BUNDLE_GEMFILE', 'gemfile')) + +ENV['ENABLE_COVERAGE'] ||= '1' + +if ENV['ENABLE_COVERAGE'].to_i.positive? + SimpleCov.command_name(digest.hexdigest) + SimpleCov.start do + add_filter %r{^/test/} + end +end 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..67b6c89a00 100644 --- a/instrumentation/trilogy/README.md +++ b/instrumentation/trilogy/README.md @@ -51,18 +51,49 @@ OpenTelemetry::Instrumentation::Trilogy.with_attributes('pizzatoppings' => 'mush end ``` +## Configuration Options + +| Option | Default | Description | +| ------ | ------- | ----------- | +| `db_statement` | `:obfuscate` | Controls how SQL queries appear in spans. `:obfuscate` replaces literal values with `?`, `:include` records the raw SQL, `:omit` excludes the attribute entirely. | +| `obfuscation_limit` | `2000` | Maximum length of the obfuscated SQL statement. Statements exceeding this limit are truncated. | +| `peer_service` | `nil` | Deprecated with no replacement. Sets the `peer.service` attribute on spans (old semantic conventions only). | +| `propagator` | `'none'` | Propagator for injecting trace context into SQL comments. `'none'` disables propagation, `'tracecontext'` uses W3C Trace Context, `'vitess'` uses Vitess-style propagation (requires `opentelemetry-propagator-vitess` gem). | +| `record_exception` | `true` | Records exceptions as span events when an error occurs. | +| `span_name` | `:statement_type` | Controls span naming (old semantic conventions only). `:statement_type` uses the SQL operation (e.g., `SELECT`), `:db_name` uses the database name, `:db_operation_and_name` combines both. | + ## Semantic Conventions -This instrumentation generally uses [Database semantic conventions](https://opentelemetry.io/docs/specs/semconv/database/database-spans/). +This instrumentation generally uses [Database semantic conventions](https://opentelemetry.io/docs/specs/semconv/database/database-spans/). See the [Database semantic convention stability](#database-semantic-convention-stability) section for how to switch between stable and old conventions. + +| Stable Attribute Name | Old Attribute Name | Type | Notes | +| - | - | - | - | +| `db.namespace` | `db.name` | String | Database name from connection_options | +| `db.query.text` | `db.statement` | String | The database query being executed; set according to the `db_statement` config option | +| `db.response.status_code` | — | String | The Trilogy error code, if available | +| `db.system.name` | `db.system` | String | DBMS product identifier; always `mysql` | +| `error.type` | — | String | The exception class name when the operation fails | +| `server.address` | `net.peer.name` | String | Database host from connection_options | +| `server.port` | — | Integer | Database port from connection_options | +| — | `db.instance.id` | String | Connected host, e.g. result of `SELECT @@hostname` | +| — | `db.user` | String | Database username from connection_options | +| — | `peer.service` | String | Configured via the `peer_service` config option | + +## 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. -| Attribute Name | Type | Notes | -| - | - | - | -| `db.instance.id` | String | The name of the DB host executing the query e.g. `SELECT @@hostname` | -| `db.name` | String | The name of the database from connection_options | -| `db.statement` | String | SQL statement being executed | -| `db.user` | String | The username from connection_options | -| `db.system` | String | `mysql` | -| `net.peer.name` | String | The name of the remote host from connection_options | +For additional information on migration, please refer to our [documentation](https://opentelemetry.io/docs/specs/semconv/non-normative/db-migration/). ## How can I get involved? 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..c2acaf113e 100644 --- a/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/instrumentation.rb +++ b/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/instrumentation.rb @@ -7,7 +7,70 @@ module OpenTelemetry module Instrumentation module Trilogy - # The Instrumentation class contains logic to detect and install the Trilogy instrumentation + # The {OpenTelemetry::Instrumentation::Trilogy::Instrumentation} class contains logic to detect and install the Trilogy instrumentation + # + # Installation and configuration of this instrumentation is done within the + # {https://www.rubydoc.info/gems/opentelemetry-sdk/OpenTelemetry/SDK#configure-instance_method OpenTelemetry::SDK#configure} + # block, calling {https://www.rubydoc.info/gems/opentelemetry-sdk/OpenTelemetry%2FSDK%2FConfigurator:use use()} + # or {https://www.rubydoc.info/gems/opentelemetry-sdk/OpenTelemetry%2FSDK%2FConfigurator:use_all use_all()}. + # + # ## Configuration keys and options + # + # ### `:db_statement` + # + # Controls how SQL queries appear in spans. + # + # - `:obfuscate` **(default)** - Replaces literal values with `?` to prevent + # sensitive data from being recorded. + # - `:include` - Records the raw SQL query as-is. + # - `:omit` - Excludes the SQL query attribute entirely. + # + # ### `:obfuscation_limit` + # + # Maximum length of the obfuscated SQL statement. Statements exceeding this limit + # are truncated. Default is `2000`. + # + # ### `:peer_service` + # + # Sets the `peer.service` attribute on spans. Default is `nil`. + # Only applies when using old semantic conventions. Deprecated with no replacement. + # + # ### `:propagator` + # + # Propagator for injecting trace context into SQL comments. + # + # - `'none'` **(default)** - Disables trace context propagation. + # - `'tracecontext'` - Uses W3C Trace Context format via SQL comments. + # - `'vitess'` - Uses Vitess-style propagation. Requires the + # `opentelemetry-propagator-vitess` gem. + # + # ### `:record_exception` + # + # Records exceptions as span events when an error occurs. Default is `true`. + # + # ### `:span_name` + # + # Controls how span names are generated. Only applies when using old semantic + # conventions; ignored for stable semantic conventions. + # + # - `:statement_type` **(default)** - Uses the SQL operation (e.g., `SELECT`). + # - `:db_name` - Uses the database name. + # - `:db_operation_and_name` - Combines the operation and database name. + # + # @example An explicit default configuration + # OpenTelemetry::SDK.configure do |c| + # c.use_all({ + # 'OpenTelemetry::Instrumentation::Trilogy' => { + # db_statement: :obfuscate, + # obfuscation_limit: 2000, + # peer_service: nil, + # propagator: 'none', + # record_exception: true, + # span_name: :statement_type, + # }, + # }) + # end + # class Instrumentation < OpenTelemetry::Instrumentation::Base install do |config| require_dependencies @@ -30,16 +93,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..0c13741b73 --- /dev/null +++ b/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/patches/dup/client.rb @@ -0,0 +1,156 @@ +# 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.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 + 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 (old only - not stable and not a db attribute) + 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.respond_to?(:error_code) && 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..c71ec91e64 --- /dev/null +++ b/instrumentation/trilogy/lib/opentelemetry/instrumentation/trilogy/patches/stable/client.rb @@ -0,0 +1,132 @@ +# 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.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 + 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 + 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.respond_to?(:error_code) && 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..ae21d7eb46 --- /dev/null +++ b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/dup/instrumentation_test.rb @@ -0,0 +1,660 @@ +# 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') + + _(span.name).must_equal 'select' + # 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 'select' + + # 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 'extracts operation name from SQL for span name' do + explain_sql = 'EXPLAIN SELECT 1' + client.query(explain_sql) + + _(span.name).must_equal 'explain' + + # 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 'select' + + # 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 'select' + + # 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 'select' + + # 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 'select' + + # 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 'select' + + # 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 '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 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 '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 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 '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_predicate(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_predicate(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_predicate(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_predicate(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_predicate(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_predicate(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 '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 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 'select' + _(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 'select' + _(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 'select' + _(span.attributes['db.statement']).must_equal obfuscated_sql + _(span.attributes['db.query.text']).must_equal obfuscated_sql + end + 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 a4ac430905..70c45c5a06 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..48c1dc4376 --- /dev/null +++ b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/stable/client_attributes_test.rb @@ -0,0 +1,187 @@ +# 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 '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..20cbc309d5 --- /dev/null +++ b/instrumentation/trilogy/test/opentelemetry/instrumentation/trilogy/patches/stable/instrumentation_test.rb @@ -0,0 +1,548 @@ +# 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 '#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) + _(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 'extracts operation name from SQL for span name' 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 mysql as span.name fallback for 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' + _(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 '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' + _(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 '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' + _(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 '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 + + 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 '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_predicate(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_predicate(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_predicate(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_predicate(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_predicate(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_predicate(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 '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 + end +end