Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Add Mysql2 and Trilogy db.collection.name attribute #1109

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
3 changes: 3 additions & 0 deletions instrumentation/mysql2/example/mysql2.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@
client.query("SELECT * from information_schema.INNODB_TABLES; /**Dé**/").each do |row|
puts row
end

client.query('CREATE TABLE test_table (id SERIAL PRIMARY KEY, name VARCHAR(50), age INT)')
client.query('DROP TABLE test_table')
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ module Mysql2
module Patches
# Module to prepend to Mysql2::Client for instrumentation
module Client
# Capture the first word (including letters, digits, underscores, & '.', ) that follows common table commands
TABLE_NAME = /\b(?:(?:FROM|INTO|UPDATE)|(?:(?:CREATE|DROP|ALTER)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?))\s+["']?([\w.]+)["']?/i

def query(sql, options = {})
tracer.in_span(
_otel_span_name(sql),
Expand Down Expand Up @@ -47,15 +50,20 @@ def _otel_span_name(sql)
end

def _otel_span_attributes(sql)
attributes = _otel_client_attributes
case config[:db_statement]
when :include
attributes[SemanticConventions::Trace::DB_STATEMENT] = sql
when :obfuscate
attributes[SemanticConventions::Trace::DB_STATEMENT] =
OpenTelemetry::Helpers::SqlObfuscation.obfuscate_sql(
sql, obfuscation_limit: config[:obfuscation_limit], adapter: :mysql
)
attributes = _otel_client_attributes(sql)

if sql
attributes[SemanticConventions::Trace::DB_SQL_TABLE] = collection_name(sql)

case config[:db_statement]
when :include
attributes[SemanticConventions::Trace::DB_STATEMENT] = sql
when :obfuscate
attributes[SemanticConventions::Trace::DB_STATEMENT] =
OpenTelemetry::Helpers::SqlObfuscation.obfuscate_sql(
sql, obfuscation_limit: config[:obfuscation_limit], adapter: :mysql
)
end
end

attributes.merge!(OpenTelemetry::Instrumentation::Mysql2.attributes)
Expand All @@ -68,7 +76,7 @@ def _otel_database_name
(query_options[:database] || query_options[:dbname] || query_options[:db])&.to_s
end

def _otel_client_attributes
def _otel_client_attributes(sql)
# The client specific attributes can be found via the query_options instance variable
# exposed on the mysql2 Client
# https://github.com/brianmario/mysql2/blob/ca08712c6c8ea672df658bb25b931fea22555f27/lib/mysql2/client.rb#L25-L26
Expand All @@ -83,9 +91,16 @@ def _otel_client_attributes

attributes[SemanticConventions::Trace::DB_NAME] = _otel_database_name
attributes[SemanticConventions::Trace::PEER_SERVICE] = config[:peer_service]

attributes
end

def collection_name(sql)
Regexp.last_match[1] if sql =~ TABLE_NAME
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could probably look this up myself but...

How does Regexp.last_match work?

Is this thread safe?

What happens if you are in a multi-threaded worker and an unrelated Regexp runs. Will it return the last_match from an unexpected regular expression?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regexp.last_match(1) is returning the first matched result and stopping. Since we just need the first match, this approach is faster than the previously used .scan, which was capturing every single match.

Re thread safety: I think so. Here's a StackOverflow answer that talks a bit about it. Because its being called inside an instance method, Regexp.last_match() should only be yielding the last capture that was evaluated inside the method, so no clobbering should be taking place.

rescue StandardError
nil
end

def tracer
Mysql2::Instrumentation.instance.tracer
end
Expand Down
54 changes: 54 additions & 0 deletions instrumentation/mysql2/test/fixtures/sql_table_name.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
[
{
"name": "from",
"sql": "SELECT * FROM test_table"
},
{
"name": "select_count_from",
"sql": "SELECT COUNT(*) FROM test_table WHERE condition"
},
{
"name": "from_with_subquery",
"sql": "SELECT * FROM (SELECT * FROM test_table) AS table_alias"
},
{
"name": "insert_into",
"sql": "INSERT INTO test_table (column1, column2) VALUES (value1, value2)"
},
{
"name": "update",
"sql": "UPDATE test_table SET column1 = value1 WHERE condition"
},
{
"name": "delete_from",
"sql": "DELETE FROM test_table WHERE condition"
},
{
"name": "create_table",
"sql": "CREATE TABLE test_table (column1 datatype, column2 datatype)"
},
{
"name": "create_table_if_not_exists",
"sql": "CREATE TABLE IF NOT EXISTS test_table (column1 datatype, column2 datatype)"
},
{
"name": "alter_table",
"sql": "ALTER TABLE test_table ADD column_name datatype"
},
{
"name": "drop_table",
"sql": "DROP TABLE test_table"
},
{
"name": "drop_table_if_exists",
"sql": "DROP TABLE IF EXISTS test_table"
},
{
"name": "insert_into",
"sql": "INSERT INTO test_table values('', 'a''b c',0, 1 , 'd''e f''s h')"
},
{
"name": "from_with_join",
"sql": "SELECT columns FROM test_table JOIN table2 ON test_table.column = table2.column"
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
# 1. Build the opentelemetry/opentelemetry-ruby-contrib image
# - docker-compose build
# 2. Bundle install
# - docker-compose run ex-instrumentation-mysql2-test bundle install
# - docker-compose run ex-instrumentation-mysql2-test bundle exec appraisal install
# 3. Run test suite
# - docker-compose run ex-instrumentation-mysql2-test bundle exec rake test
# - docker-compose run ex-instrumentation-mysql2-test bundle exec appraisal rake test
describe OpenTelemetry::Instrumentation::Mysql2::Instrumentation do
let(:instrumentation) { OpenTelemetry::Instrumentation::Mysql2::Instrumentation.instance }
let(:exporter) { EXPORTER }
Expand Down Expand Up @@ -473,6 +473,24 @@
end
end
end

describe '#connection_name' do
def self.load_fixture
data = File.read("#{Dir.pwd}/test/fixtures/sql_table_name.json")
JSON.parse(data)
end

load_fixture.each do |test_case|
name = test_case['name']
query = test_case['sql']

it "returns the table name for #{name}" do
table_name = client.send(:collection_name, query)

expect(table_name).must_equal('test_table')
end
end
end
end
end unless ENV['OMIT_SERVICES']
end
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ module Patches
# Module to prepend to PG::Connection for instrumentation
module Connection # rubocop:disable Metrics/ModuleLength
# Capture the first word (including letters, digits, underscores, & '.', ) that follows common table commands
TABLE_NAME = /\b(?:FROM|INTO|UPDATE|CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?|DROP\s+TABLE(?:\s+IF\s+EXISTS)?|ALTER\s+TABLE(?:\s+IF\s+EXISTS)?)\s+([\w\.]+)/i
TABLE_NAME = /\b(?:(?:FROM|INTO|UPDATE)|(?:(?:CREATE|DROP|ALTER)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?))\s+["']?([\w.]+)["']?/i

PG::Constants::EXEC_ISH_METHODS.each do |method|
define_method method do |*args, &block|
Expand Down Expand Up @@ -132,7 +132,7 @@ def validated_operation(operation)
end

def collection_name(text)
text.scan(TABLE_NAME).flatten[0]
Regexp.last_match[1] if text =~ TABLE_NAME
rescue StandardError
nil
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ module Trilogy
module Patches
# Module to prepend to Trilogy for instrumentation
module Client
# Capture the first word (including letters, digits, underscores, & '.', ) that follows common table commands
TABLE_NAME = /\b(?:(?:FROM|INTO|UPDATE)|(?:(?:CREATE|DROP|ALTER)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?))\s+["']?([\w.]+)["']?/i

def initialize(options = {})
@connection_options = options # This is normally done by Trilogy#initialize

Expand Down Expand Up @@ -76,6 +79,9 @@ def client_attributes(sql = nil)
attributes['db.instance.id'] = @connected_host unless @connected_host.nil?

if sql
collection_name = collection_name(sql)
attributes[SemanticConventions::Trace::DB_SQL_TABLE] = collection_name if collection_name

case config[:db_statement]
when :obfuscate
attributes[::OpenTelemetry::SemanticConventions::Trace::DB_STATEMENT] =
Expand All @@ -88,6 +94,12 @@ def client_attributes(sql = nil)
attributes
end

def collection_name(sql)
Regexp.last_match[1] if sql =~ TABLE_NAME
rescue StandardError
nil
end

def database_name
connection_options[:database]
end
Expand Down
54 changes: 54 additions & 0 deletions instrumentation/trilogy/test/fixtures/sql_table_name.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
[
{
"name": "from",
"sql": "SELECT * FROM test_table"
},
{
"name": "select_count_from",
"sql": "SELECT COUNT(*) FROM test_table WHERE condition"
},
{
"name": "from_with_subquery",
"sql": "SELECT * FROM (SELECT * FROM test_table) AS table_alias"
},
{
"name": "insert_into",
"sql": "INSERT INTO test_table (column1, column2) VALUES (value1, value2)"
},
{
"name": "update",
"sql": "UPDATE test_table SET column1 = value1 WHERE condition"
},
{
"name": "delete_from",
"sql": "DELETE FROM test_table WHERE condition"
},
{
"name": "create_table",
"sql": "CREATE TABLE test_table (column1 datatype, column2 datatype)"
},
{
"name": "create_table_if_not_exists",
"sql": "CREATE TABLE IF NOT EXISTS test_table (column1 datatype, column2 datatype)"
},
{
"name": "alter_table",
"sql": "ALTER TABLE test_table ADD column_name datatype"
},
{
"name": "drop_table",
"sql": "DROP TABLE test_table"
},
{
"name": "drop_table_if_exists",
"sql": "DROP TABLE IF EXISTS test_table"
},
{
"name": "insert_into",
"sql": "INSERT INTO test_table values('', 'a''b c',0, 1 , 'd''e f''s h')"
},
{
"name": "from_with_join",
"sql": "SELECT columns FROM test_table JOIN table2 ON test_table.column = table2.column"
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@
require_relative '../../../../lib/opentelemetry/instrumentation/trilogy'
require_relative '../../../../lib/opentelemetry/instrumentation/trilogy/patches/client'

# This test suite requires a running mysql service within a dedicated test container. We can use the same
# docker-compose service as the mysql2 instrumentation tests. The test container should run the mysql client.
# To run tests locally:
# 1. Build the opentelemetry/opentelemetry-ruby-contrib image
# - docker-compose build
# 2. Open a bash shell in the test container and cd to the trilogy directory
# - docker-compose run ex-instrumentation-mysql2-test bash -c 'cd ../trilogy && bash'
# 3. Bundle install and run tests with the Appraisals gem
# - bundle exec appraisal install && bundle exec appraisal rake test

describe OpenTelemetry::Instrumentation::Trilogy do
let(:instrumentation) { OpenTelemetry::Instrumentation::Trilogy::Instrumentation.instance }
let(:exporter) { EXPORTER }
Expand Down Expand Up @@ -626,5 +636,23 @@
end
end
end

describe '#connection_name' do
def self.load_fixture
data = File.read("#{Dir.pwd}/test/fixtures/sql_table_name.json")
JSON.parse(data)
end

load_fixture.each do |test_case|
name = test_case['name']
query = test_case['sql']

it "returns the table name for #{name}" do
table_name = client.send(:collection_name, query)

expect(table_name).must_equal('test_table')
end
end
end
end
end
Loading