From 093ef23c08589489acbb1631e4793ffa587ce0e8 Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Wed, 25 Mar 2026 23:46:39 +0100 Subject: [PATCH 01/25] Extract db.namespace from DSN in AttributesFromDSN --- CHANGELOG.md | 4 +++ helpers.go | 86 +++++++++++++++++++++++++++---------------------- helpers_test.go | 52 +++++++++++++++++++++++++++--- 3 files changed, 100 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42bf6770..e883c64f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Added + +- `AttributesFromDSN` now extracts the database name from the DSN and sets it as the `db.namespace` attribute ([`semconv.DBNamespaceKey`](https://opentelemetry.io/docs/specs/semconv/database/database-spans/#common-attributes)). + ## [0.41.0] - 2025-12-16 ### ⚠️ Notice ⚠️ diff --git a/helpers.go b/helpers.go index 992282e2..a0a994fa 100644 --- a/helpers.go +++ b/helpers.go @@ -24,61 +24,71 @@ import ( ) // AttributesFromDSN returns attributes extracted from a DSN string. -// It makes the best effort to retrieve values for [semconv.ServerAddressKey] and [semconv.ServerPortKey]. +// It makes the best effort to retrieve values for [semconv.ServerAddressKey], [semconv.ServerPortKey], +// and [semconv.DBNamespaceKey]. func AttributesFromDSN(dsn string) []attribute.KeyValue { - // [scheme://][user[:password]@][protocol([addr])]/dbname[?param1=value1¶mN=valueN] - // Find the schema part. - schemaIndex := strings.Index(dsn, "://") - if schemaIndex != -1 { - // Remove the schema part from the DSN. - dsn = dsn[schemaIndex+3:] + _, serverAddress, serverPort, dbName := parseDSN(dsn) + + var attrs []attribute.KeyValue + if serverAddress != "" { + attrs = append(attrs, semconv.ServerAddress(serverAddress)) + } + + if serverPort != -1 { + attrs = append(attrs, semconv.ServerPortKey.Int64(serverPort)) + } + + if dbName != "" { + attrs = append(attrs, semconv.DBNamespace(dbName)) + } + + return attrs +} + +// parseDSN parses a DSN string and returns the scheme, server address, server port, and database name. +// It handles the format: [scheme://][user[:password]@][protocol([addr])]/dbname[?params] +// scheme, serverAddress and dbName are empty strings if not found. serverPort is -1 if not found. +func parseDSN(dsn string) (scheme, serverAddress string, serverPort int64, dbName string) { + serverPort = -1 + + if i := strings.Index(dsn, "://"); i != -1 { + scheme = dsn[:i] + dsn = dsn[i+3:] } - // [user[:password]@][protocol([addr])]/dbname[?param1=value1¶mN=valueN] - // Find credentials part. - atIndex := strings.Index(dsn, "@") - if atIndex != -1 { - // Remove the credential part from the DSN. - dsn = dsn[atIndex+1:] + if i := strings.Index(dsn, "@"); i != -1 { + dsn = dsn[i+1:] } - // [protocol([addr])]/dbname[?param1=value1¶mN=valueN] - // Find the '/' that separates the address part from the database part. - pathIndex := strings.Index(dsn, "/") - if pathIndex != -1 { - // Remove the path part from the DSN. - dsn = dsn[:pathIndex] + if i := strings.Index(dsn, "/"); i != -1 { + path := dsn[i+1:] + if j := strings.Index(path, "?"); j != -1 { + path = path[:j] + } + + dbName = path + dsn = dsn[:i] } - // [protocol([addr])] or [addr] - // Find the '(' that starts the address part. - openParen := strings.Index(dsn, "(") - if openParen != -1 { - // Remove the protocol part from the DSN. - dsn = dsn[openParen+1 : len(dsn)-1] + if i := strings.Index(dsn, "("); i != -1 { + dsn = dsn[i+1 : len(dsn)-1] } - // [addr] if len(dsn) == 0 { - return nil + return scheme, serverAddress, serverPort, dbName } host, portStr, err := net.SplitHostPort(dsn) if err != nil { - host = dsn + serverAddress = dsn + return scheme, serverAddress, serverPort, dbName } - attrs := make([]attribute.KeyValue, 0, 2) - if host != "" { - attrs = append(attrs, semconv.ServerAddress(host)) - } + serverAddress = host - if portStr != "" { - port, err := strconv.ParseInt(portStr, 10, 64) - if err == nil { - attrs = append(attrs, semconv.ServerPortKey.Int64(port)) - } + if port, err := strconv.ParseInt(portStr, 10, 64); err == nil { + serverPort = port } - return attrs + return scheme, serverAddress, serverPort, dbName } diff --git a/helpers_test.go b/helpers_test.go index e3654442..ea15b61c 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -27,10 +27,18 @@ func TestAttributesFromDSN(t *testing.T) { dsn string expected []attribute.KeyValue }{ + { + dsn: "mysql://root:otel_password@example.com/db", + expected: []attribute.KeyValue{ + semconv.ServerAddress("example.com"), + semconv.DBNamespace("db"), + }, + }, { dsn: "mysql://root:otel_password@tcp(example.com)/db?parseTime=true", expected: []attribute.KeyValue{ semconv.ServerAddress("example.com"), + semconv.DBNamespace("db"), }, }, { @@ -38,6 +46,7 @@ func TestAttributesFromDSN(t *testing.T) { expected: []attribute.KeyValue{ semconv.ServerAddress("example.com"), semconv.ServerPort(3307), + semconv.DBNamespace("db"), }, }, { @@ -45,18 +54,21 @@ func TestAttributesFromDSN(t *testing.T) { expected: []attribute.KeyValue{ semconv.ServerAddress("2001:db8:1234:5678:9abc:def0:0001"), semconv.ServerPort(3307), + semconv.DBNamespace("db"), }, }, { dsn: "mysql://root:otel_password@tcp(2001:db8:1234:5678:9abc:def0:0001)/db?parseTime=true", expected: []attribute.KeyValue{ semconv.ServerAddress("2001:db8:1234:5678:9abc:def0:0001"), + semconv.DBNamespace("db"), }, }, { dsn: "root:secret@tcp(mysql)/db?parseTime=true", expected: []attribute.KeyValue{ semconv.ServerAddress("mysql"), + semconv.DBNamespace("db"), }, }, { @@ -64,16 +76,20 @@ func TestAttributesFromDSN(t *testing.T) { expected: []attribute.KeyValue{ semconv.ServerAddress("mysql"), semconv.ServerPort(3307), + semconv.DBNamespace("db"), }, }, { - dsn: "root:secret@/db?parseTime=true", - expected: nil, + dsn: "root:secret@/db?parseTime=true", + expected: []attribute.KeyValue{ + semconv.DBNamespace("db"), + }, }, { dsn: "example.com/db?parseTime=true", expected: []attribute.KeyValue{ semconv.ServerAddress("example.com"), + semconv.DBNamespace("db"), }, }, { @@ -81,6 +97,7 @@ func TestAttributesFromDSN(t *testing.T) { expected: []attribute.KeyValue{ semconv.ServerAddress("example.com"), semconv.ServerPort(3307), + semconv.DBNamespace("db"), }, }, { @@ -102,17 +119,26 @@ func TestAttributesFromDSN(t *testing.T) { semconv.ServerAddress("example.com"), }, }, + { + dsn: "example.com/db", + expected: []attribute.KeyValue{ + semconv.ServerAddress("example.com"), + semconv.DBNamespace("db"), + }, + }, { dsn: "postgres://root:secret@0.0.0.0:42/db?param1=value1¶mN=valueN", expected: []attribute.KeyValue{ semconv.ServerAddress("0.0.0.0"), semconv.ServerPort(42), + semconv.DBNamespace("db"), }, }, { dsn: "postgres://root:secret@2001:db8:1234:5678:9abc:def0:0001/db?param1=value1¶mN=valueN", expected: []attribute.KeyValue{ semconv.ServerAddress("2001:db8:1234:5678:9abc:def0:0001"), + semconv.DBNamespace("db"), }, }, { @@ -120,6 +146,7 @@ func TestAttributesFromDSN(t *testing.T) { expected: []attribute.KeyValue{ semconv.ServerAddress("2001:db8:1234:5678:9abc:def0:0001"), semconv.ServerPort(42), + semconv.DBNamespace("db"), }, }, { @@ -127,6 +154,7 @@ func TestAttributesFromDSN(t *testing.T) { expected: []attribute.KeyValue{ semconv.ServerAddress("0.0.0.0"), semconv.ServerPort(42), + semconv.DBNamespace("db"), }, }, { @@ -134,14 +162,30 @@ func TestAttributesFromDSN(t *testing.T) { dsn: "root:secret@tcp/db?param1=value1¶mN=valueN", expected: []attribute.KeyValue{ semconv.ServerAddress("tcp"), + semconv.DBNamespace("db"), + }, + }, + { + // DSN lacking a db-name + dsn: "sqlserver://user:pass@dbhost:1433", + expected: []attribute.KeyValue{ + semconv.ServerAddress("dbhost"), + semconv.ServerPort(1433), + }, + }, + { + // DSN lacking a db-name, with trailing '/' + dsn: "postgres://user:pass@dbhost/", + expected: []attribute.KeyValue{ + semconv.ServerAddress("dbhost"), }, }, } for _, tc := range testCases { t.Run(tc.dsn, func(t *testing.T) { - got := AttributesFromDSN(tc.dsn) - assert.Equal(t, tc.expected, got) + gotAttrs := AttributesFromDSN(tc.dsn) + assert.Equal(t, tc.expected, gotAttrs) }) } } From 6fabdcaea3413f4935c60be0a5f2255c922afa09 Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Wed, 25 Mar 2026 23:46:39 +0100 Subject: [PATCH 02/25] Extract db.namespace from DSN in AttributesFromDSN --- helpers.go | 54 ++++++++++++++++++++++++++++++++++++++++++++++--- helpers_test.go | 30 +++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/helpers.go b/helpers.go index a0a994fa..0fd0b3dc 100644 --- a/helpers.go +++ b/helpers.go @@ -24,12 +24,16 @@ import ( ) // AttributesFromDSN returns attributes extracted from a DSN string. -// It makes the best effort to retrieve values for [semconv.ServerAddressKey], [semconv.ServerPortKey], -// and [semconv.DBNamespaceKey]. +// It always sets [semconv.DBSystemNameKey], falling back to [semconv.DBSystemNameOtherSQL] when +// the scheme is missing or unrecognized. It makes the best effort to retrieve values for +// [semconv.ServerAddressKey], [semconv.ServerPortKey], and [semconv.DBNamespaceKey]. func AttributesFromDSN(dsn string) []attribute.KeyValue { - _, serverAddress, serverPort, dbName := parseDSN(dsn) + scheme, serverAddress, serverPort, dbName := parseDSN(dsn) var attrs []attribute.KeyValue + + attrs = append(attrs, dbSystemFromScheme(scheme)) + if serverAddress != "" { attrs = append(attrs, semconv.ServerAddress(serverAddress)) } @@ -45,6 +49,50 @@ func AttributesFromDSN(dsn string) []attribute.KeyValue { return attrs } +// dbSystemByScheme maps lowercase DSN schemes to their [semconv.DBSystemNameKey] attribute. +var dbSystemByScheme = map[string]attribute.KeyValue{ + "mysql": semconv.DBSystemNameMySQL, + "postgres": semconv.DBSystemNamePostgreSQL, + "postgresql": semconv.DBSystemNamePostgreSQL, + "sqlserver": semconv.DBSystemNameMicrosoftSQLServer, + "mssql": semconv.DBSystemNameMicrosoftSQLServer, + "oracle": semconv.DBSystemNameOracleDB, + "oracle+cx_oracle": semconv.DBSystemNameOracleDB, + "sqlite": semconv.DBSystemNameSqlite, + "sqlite3": semconv.DBSystemNameSqlite, + "mariadb": semconv.DBSystemNameMariaDB, + "cockroachdb": semconv.DBSystemNameCockroachdb, + "cockroach": semconv.DBSystemNameCockroachdb, + "cassandra": semconv.DBSystemNameCassandra, + "redis": semconv.DBSystemNameRedis, + "rediss": semconv.DBSystemNameRedis, + "mongodb": semconv.DBSystemNameMongoDB, + "mongodb+srv": semconv.DBSystemNameMongoDB, + "clickhouse": semconv.DBSystemNameClickhouse, + "trino": semconv.DBSystemNameTrino, + "hive": semconv.DBSystemNameHive, + "spanner": semconv.DBSystemNameGCPSpanner, + "elasticsearch": semconv.DBSystemNameElasticsearch, + "couchbase": semconv.DBSystemNameCouchbase, + "influxdb": semconv.DBSystemNameInfluxdb, + "dynamodb": semconv.DBSystemNameAWSDynamoDB, + "redshift": semconv.DBSystemNameAWSRedshift, + "teradata": semconv.DBSystemNameTeradata, + "firebird": semconv.DBSystemNameFirebirdsql, + "firebirdsql": semconv.DBSystemNameFirebirdsql, + "hbase": semconv.DBSystemNameHBase, +} + +// dbSystemFromScheme maps a DSN scheme to the corresponding [semconv.DBSystemNameKey] attribute. +// It returns [semconv.DBSystemNameOtherSQL] if the scheme is not recognized. +func dbSystemFromScheme(scheme string) attribute.KeyValue { + if v, ok := dbSystemByScheme[strings.ToLower(scheme)]; ok { + return v + } + + return semconv.DBSystemNameOtherSQL +} + // parseDSN parses a DSN string and returns the scheme, server address, server port, and database name. // It handles the format: [scheme://][user[:password]@][protocol([addr])]/dbname[?params] // scheme, serverAddress and dbName are empty strings if not found. serverPort is -1 if not found. diff --git a/helpers_test.go b/helpers_test.go index ea15b61c..5f319bd9 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -30,6 +30,7 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "mysql://root:otel_password@example.com/db", expected: []attribute.KeyValue{ + semconv.DBSystemNameMySQL, semconv.ServerAddress("example.com"), semconv.DBNamespace("db"), }, @@ -37,6 +38,7 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "mysql://root:otel_password@tcp(example.com)/db?parseTime=true", expected: []attribute.KeyValue{ + semconv.DBSystemNameMySQL, semconv.ServerAddress("example.com"), semconv.DBNamespace("db"), }, @@ -44,6 +46,7 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "mysql://root:otel_password@tcp(example.com:3307)/db?parseTime=true", expected: []attribute.KeyValue{ + semconv.DBSystemNameMySQL, semconv.ServerAddress("example.com"), semconv.ServerPort(3307), semconv.DBNamespace("db"), @@ -52,6 +55,7 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "mysql://root:otel_password@tcp([2001:db8:1234:5678:9abc:def0:0001]:3307)/db?parseTime=true", expected: []attribute.KeyValue{ + semconv.DBSystemNameMySQL, semconv.ServerAddress("2001:db8:1234:5678:9abc:def0:0001"), semconv.ServerPort(3307), semconv.DBNamespace("db"), @@ -60,6 +64,7 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "mysql://root:otel_password@tcp(2001:db8:1234:5678:9abc:def0:0001)/db?parseTime=true", expected: []attribute.KeyValue{ + semconv.DBSystemNameMySQL, semconv.ServerAddress("2001:db8:1234:5678:9abc:def0:0001"), semconv.DBNamespace("db"), }, @@ -67,6 +72,7 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "root:secret@tcp(mysql)/db?parseTime=true", expected: []attribute.KeyValue{ + semconv.DBSystemNameOtherSQL, semconv.ServerAddress("mysql"), semconv.DBNamespace("db"), }, @@ -74,6 +80,7 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "root:secret@tcp(mysql:3307)/db?parseTime=true", expected: []attribute.KeyValue{ + semconv.DBSystemNameOtherSQL, semconv.ServerAddress("mysql"), semconv.ServerPort(3307), semconv.DBNamespace("db"), @@ -82,12 +89,14 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "root:secret@/db?parseTime=true", expected: []attribute.KeyValue{ + semconv.DBSystemNameOtherSQL, semconv.DBNamespace("db"), }, }, { dsn: "example.com/db?parseTime=true", expected: []attribute.KeyValue{ + semconv.DBSystemNameOtherSQL, semconv.ServerAddress("example.com"), semconv.DBNamespace("db"), }, @@ -95,6 +104,7 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "example.com:3307/db?parseTime=true", expected: []attribute.KeyValue{ + semconv.DBSystemNameOtherSQL, semconv.ServerAddress("example.com"), semconv.ServerPort(3307), semconv.DBNamespace("db"), @@ -103,6 +113,7 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "example.com:3307", expected: []attribute.KeyValue{ + semconv.DBSystemNameOtherSQL, semconv.ServerAddress("example.com"), semconv.ServerPort(3307), }, @@ -110,18 +121,21 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "example.com:", expected: []attribute.KeyValue{ + semconv.DBSystemNameOtherSQL, semconv.ServerAddress("example.com"), }, }, { dsn: "example.com", expected: []attribute.KeyValue{ + semconv.DBSystemNameOtherSQL, semconv.ServerAddress("example.com"), }, }, { dsn: "example.com/db", expected: []attribute.KeyValue{ + semconv.DBSystemNameOtherSQL, semconv.ServerAddress("example.com"), semconv.DBNamespace("db"), }, @@ -129,6 +143,7 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "postgres://root:secret@0.0.0.0:42/db?param1=value1¶mN=valueN", expected: []attribute.KeyValue{ + semconv.DBSystemNamePostgreSQL, semconv.ServerAddress("0.0.0.0"), semconv.ServerPort(42), semconv.DBNamespace("db"), @@ -137,6 +152,7 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "postgres://root:secret@2001:db8:1234:5678:9abc:def0:0001/db?param1=value1¶mN=valueN", expected: []attribute.KeyValue{ + semconv.DBSystemNamePostgreSQL, semconv.ServerAddress("2001:db8:1234:5678:9abc:def0:0001"), semconv.DBNamespace("db"), }, @@ -144,6 +160,7 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "postgres://root:secret@[2001:db8:1234:5678:9abc:def0:0001]:42/db?param1=value1¶mN=valueN", expected: []attribute.KeyValue{ + semconv.DBSystemNamePostgreSQL, semconv.ServerAddress("2001:db8:1234:5678:9abc:def0:0001"), semconv.ServerPort(42), semconv.DBNamespace("db"), @@ -152,6 +169,7 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "root:secret@0.0.0.0:42/db?param1=value1¶mN=valueN", expected: []attribute.KeyValue{ + semconv.DBSystemNameOtherSQL, semconv.ServerAddress("0.0.0.0"), semconv.ServerPort(42), semconv.DBNamespace("db"), @@ -161,6 +179,7 @@ func TestAttributesFromDSN(t *testing.T) { // In this case, "tcp" will be considered as the server address. dsn: "root:secret@tcp/db?param1=value1¶mN=valueN", expected: []attribute.KeyValue{ + semconv.DBSystemNameOtherSQL, semconv.ServerAddress("tcp"), semconv.DBNamespace("db"), }, @@ -169,6 +188,7 @@ func TestAttributesFromDSN(t *testing.T) { // DSN lacking a db-name dsn: "sqlserver://user:pass@dbhost:1433", expected: []attribute.KeyValue{ + semconv.DBSystemNameMicrosoftSQLServer, semconv.ServerAddress("dbhost"), semconv.ServerPort(1433), }, @@ -177,9 +197,19 @@ func TestAttributesFromDSN(t *testing.T) { // DSN lacking a db-name, with trailing '/' dsn: "postgres://user:pass@dbhost/", expected: []attribute.KeyValue{ + semconv.DBSystemNamePostgreSQL, semconv.ServerAddress("dbhost"), }, }, + { + // Unrecognized scheme falls back to OtherSQL + dsn: "unknown://user:pass@dbhost/db", + expected: []attribute.KeyValue{ + semconv.DBSystemNameOtherSQL, + semconv.ServerAddress("dbhost"), + semconv.DBNamespace("db"), + }, + }, } for _, tc := range testCases { From e4d5e0e2371cfb03cf0452148ed447dbeae4cea9 Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Thu, 26 Mar 2026 10:39:26 +0100 Subject: [PATCH 03/25] Better code coverage through unit tests --- helpers.go | 2 +- helpers_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/helpers.go b/helpers.go index 0fd0b3dc..72a9ab42 100644 --- a/helpers.go +++ b/helpers.go @@ -84,7 +84,7 @@ var dbSystemByScheme = map[string]attribute.KeyValue{ } // dbSystemFromScheme maps a DSN scheme to the corresponding [semconv.DBSystemNameKey] attribute. -// It returns [semconv.DBSystemNameOtherSQL] if the scheme is not recognized. +// It returns [semconv.DBSystemNameOtherSQL] if the scheme is not recognized or missing. func dbSystemFromScheme(scheme string) attribute.KeyValue { if v, ok := dbSystemByScheme[strings.ToLower(scheme)]; ok { return v diff --git a/helpers_test.go b/helpers_test.go index 5f319bd9..9d7ebd85 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -219,3 +219,53 @@ func TestAttributesFromDSN(t *testing.T) { }) } } + +func TestDBSystemFromScheme(t *testing.T) { + testCases := []struct { + scheme string + expected attribute.KeyValue + }{ + {"mysql", semconv.DBSystemNameMySQL}, + {"postgres", semconv.DBSystemNamePostgreSQL}, + {"postgresql", semconv.DBSystemNamePostgreSQL}, + {"sqlserver", semconv.DBSystemNameMicrosoftSQLServer}, + {"mssql", semconv.DBSystemNameMicrosoftSQLServer}, + {"oracle", semconv.DBSystemNameOracleDB}, + {"oracle+cx_oracle", semconv.DBSystemNameOracleDB}, + {"sqlite", semconv.DBSystemNameSqlite}, + {"sqlite3", semconv.DBSystemNameSqlite}, + {"mariadb", semconv.DBSystemNameMariaDB}, + {"cockroachdb", semconv.DBSystemNameCockroachdb}, + {"cockroach", semconv.DBSystemNameCockroachdb}, + {"cassandra", semconv.DBSystemNameCassandra}, + {"redis", semconv.DBSystemNameRedis}, + {"rediss", semconv.DBSystemNameRedis}, + {"mongodb", semconv.DBSystemNameMongoDB}, + {"mongodb+srv", semconv.DBSystemNameMongoDB}, + {"clickhouse", semconv.DBSystemNameClickhouse}, + {"trino", semconv.DBSystemNameTrino}, + {"hive", semconv.DBSystemNameHive}, + {"spanner", semconv.DBSystemNameGCPSpanner}, + {"elasticsearch", semconv.DBSystemNameElasticsearch}, + {"couchbase", semconv.DBSystemNameCouchbase}, + {"influxdb", semconv.DBSystemNameInfluxdb}, + {"dynamodb", semconv.DBSystemNameAWSDynamoDB}, + {"redshift", semconv.DBSystemNameAWSRedshift}, + {"teradata", semconv.DBSystemNameTeradata}, + {"firebird", semconv.DBSystemNameFirebirdsql}, + {"firebirdsql", semconv.DBSystemNameFirebirdsql}, + {"hbase", semconv.DBSystemNameHBase}, + // Case-insensitive + {"MySQL", semconv.DBSystemNameMySQL}, + {"POSTGRES", semconv.DBSystemNamePostgreSQL}, + // Unknown and empty schemes fall back to OtherSQL + {"unknown", semconv.DBSystemNameOtherSQL}, + {"", semconv.DBSystemNameOtherSQL}, + } + + for _, tc := range testCases { + t.Run(tc.scheme, func(t *testing.T) { + assert.Equal(t, tc.expected, dbSystemFromScheme(tc.scheme)) + }) + } +} From ae48eda16917cbcea5bdf69b5952a8534e239c96 Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Thu, 26 Mar 2026 11:30:39 +0100 Subject: [PATCH 04/25] Fix test-coverage goal --- Makefile | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 5e1a7025..ea320d70 100644 --- a/Makefile +++ b/Makefile @@ -97,8 +97,14 @@ test-coverage: (cd "$${dir}" && \ $(GO) list ./... \ | grep -v third_party \ + | grep -v internal \ | xargs $(GO) test -coverpkg=./... -covermode=$(COVERAGE_MODE) -coverprofile="$(COVERAGE_PROFILE)" && \ - $(GO) tool cover -html=coverage.out -o coverage.html); \ + $(GO) list ./... \ + | grep -v third_party \ + | grep internal \ + | xargs $(GO) test -coverpkg=$$($(GO) list ./... | grep internal | tr '\n' ',') -covermode=$(COVERAGE_MODE) -coverprofile="$(COVERAGE_PROFILE).internal" && \ + grep -v "^mode:" "$(COVERAGE_PROFILE).internal" >> "$(COVERAGE_PROFILE)" && \ + $(GO) tool cover -html=$(COVERAGE_PROFILE) -o coverage.html); \ [ -f "$${dir}/coverage.out" ] && cat "$${dir}/coverage.out" >> coverage.txt; \ done; \ sed -i.bak -e '2,$$ { /^mode: /d; }' coverage.txt From 0115bb2f3f6140b4f19a411097ef835f5f3036db Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Thu, 26 Mar 2026 12:07:19 +0100 Subject: [PATCH 05/25] update changelog.md --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e883c64f..8ef9c026 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added -- `AttributesFromDSN` now extracts the database name from the DSN and sets it as the `db.namespace` attribute ([`semconv.DBNamespaceKey`](https://opentelemetry.io/docs/specs/semconv/database/database-spans/#common-attributes)). +- `AttributesFromDSN` now extracts the database name from the DSN and sets it as the `db.namespace` attribute + ([`semconv.DBNamespaceKey`](https://opentelemetry.io/docs/specs/semconv/database/database-spans/#common-attributes)) + and the database system (e.g. `mysql` or `postgresql`) and sets it as the `db.system.name` attribute + ([`semconv.DBSystemNameKey`](https://opentelemetry.io/docs/specs/semconv/database/database-spans/#common-attributes)). ## [0.41.0] - 2025-12-16 From 3582ac675bdc7ccbfbd43d9ea23b1a44579c0272 Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Tue, 31 Mar 2026 12:56:57 +0200 Subject: [PATCH 06/25] Better comments --- helpers.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/helpers.go b/helpers.go index 72a9ab42..6b04eb50 100644 --- a/helpers.go +++ b/helpers.go @@ -99,15 +99,18 @@ func dbSystemFromScheme(scheme string) attribute.KeyValue { func parseDSN(dsn string) (scheme, serverAddress string, serverPort int64, dbName string) { serverPort = -1 + // Extract scheme if i := strings.Index(dsn, "://"); i != -1 { scheme = dsn[:i] dsn = dsn[i+3:] } + // Skip credentials if i := strings.Index(dsn, "@"); i != -1 { dsn = dsn[i+1:] } + // Extract db name if i := strings.Index(dsn, "/"); i != -1 { path := dsn[i+1:] if j := strings.Index(path, "?"); j != -1 { @@ -118,6 +121,7 @@ func parseDSN(dsn string) (scheme, serverAddress string, serverPort int64, dbNam dsn = dsn[:i] } + // Skip protocol if i := strings.Index(dsn, "("); i != -1 { dsn = dsn[i+1 : len(dsn)-1] } @@ -126,6 +130,7 @@ func parseDSN(dsn string) (scheme, serverAddress string, serverPort int64, dbNam return scheme, serverAddress, serverPort, dbName } + // Extract host and port host, portStr, err := net.SplitHostPort(dsn) if err != nil { serverAddress = dsn From dce7fbdcbc336ab350f57aef1c188fb3aeedcaaa Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Tue, 31 Mar 2026 19:20:08 +0200 Subject: [PATCH 07/25] Remove db.system.name from AttributesFromDSN AttributesFromDSN no longer captures the db.system.name attribute, as callers are better positioned to set it based on the driver in use. The dbSystemFromScheme helper and its scheme map are removed accordingly. --- helpers.go | 51 ++-------------------------------- helpers_test.go | 73 ------------------------------------------------- 2 files changed, 2 insertions(+), 122 deletions(-) diff --git a/helpers.go b/helpers.go index 6b04eb50..57b8d553 100644 --- a/helpers.go +++ b/helpers.go @@ -24,16 +24,13 @@ import ( ) // AttributesFromDSN returns attributes extracted from a DSN string. -// It always sets [semconv.DBSystemNameKey], falling back to [semconv.DBSystemNameOtherSQL] when -// the scheme is missing or unrecognized. It makes the best effort to retrieve values for +// It makes the best effort to retrieve values for // [semconv.ServerAddressKey], [semconv.ServerPortKey], and [semconv.DBNamespaceKey]. func AttributesFromDSN(dsn string) []attribute.KeyValue { - scheme, serverAddress, serverPort, dbName := parseDSN(dsn) + _, serverAddress, serverPort, dbName := parseDSN(dsn) var attrs []attribute.KeyValue - attrs = append(attrs, dbSystemFromScheme(scheme)) - if serverAddress != "" { attrs = append(attrs, semconv.ServerAddress(serverAddress)) } @@ -49,50 +46,6 @@ func AttributesFromDSN(dsn string) []attribute.KeyValue { return attrs } -// dbSystemByScheme maps lowercase DSN schemes to their [semconv.DBSystemNameKey] attribute. -var dbSystemByScheme = map[string]attribute.KeyValue{ - "mysql": semconv.DBSystemNameMySQL, - "postgres": semconv.DBSystemNamePostgreSQL, - "postgresql": semconv.DBSystemNamePostgreSQL, - "sqlserver": semconv.DBSystemNameMicrosoftSQLServer, - "mssql": semconv.DBSystemNameMicrosoftSQLServer, - "oracle": semconv.DBSystemNameOracleDB, - "oracle+cx_oracle": semconv.DBSystemNameOracleDB, - "sqlite": semconv.DBSystemNameSqlite, - "sqlite3": semconv.DBSystemNameSqlite, - "mariadb": semconv.DBSystemNameMariaDB, - "cockroachdb": semconv.DBSystemNameCockroachdb, - "cockroach": semconv.DBSystemNameCockroachdb, - "cassandra": semconv.DBSystemNameCassandra, - "redis": semconv.DBSystemNameRedis, - "rediss": semconv.DBSystemNameRedis, - "mongodb": semconv.DBSystemNameMongoDB, - "mongodb+srv": semconv.DBSystemNameMongoDB, - "clickhouse": semconv.DBSystemNameClickhouse, - "trino": semconv.DBSystemNameTrino, - "hive": semconv.DBSystemNameHive, - "spanner": semconv.DBSystemNameGCPSpanner, - "elasticsearch": semconv.DBSystemNameElasticsearch, - "couchbase": semconv.DBSystemNameCouchbase, - "influxdb": semconv.DBSystemNameInfluxdb, - "dynamodb": semconv.DBSystemNameAWSDynamoDB, - "redshift": semconv.DBSystemNameAWSRedshift, - "teradata": semconv.DBSystemNameTeradata, - "firebird": semconv.DBSystemNameFirebirdsql, - "firebirdsql": semconv.DBSystemNameFirebirdsql, - "hbase": semconv.DBSystemNameHBase, -} - -// dbSystemFromScheme maps a DSN scheme to the corresponding [semconv.DBSystemNameKey] attribute. -// It returns [semconv.DBSystemNameOtherSQL] if the scheme is not recognized or missing. -func dbSystemFromScheme(scheme string) attribute.KeyValue { - if v, ok := dbSystemByScheme[strings.ToLower(scheme)]; ok { - return v - } - - return semconv.DBSystemNameOtherSQL -} - // parseDSN parses a DSN string and returns the scheme, server address, server port, and database name. // It handles the format: [scheme://][user[:password]@][protocol([addr])]/dbname[?params] // scheme, serverAddress and dbName are empty strings if not found. serverPort is -1 if not found. diff --git a/helpers_test.go b/helpers_test.go index 722ca0b7..7e7b2651 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -31,7 +31,6 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "mysql://root:otel_password@example.com/db", expected: []attribute.KeyValue{ - semconv.DBSystemNameMySQL, semconv.ServerAddress("example.com"), semconv.DBNamespace("db"), }, @@ -39,7 +38,6 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "mysql://root:otel_password@tcp(example.com)/db?parseTime=true", expected: []attribute.KeyValue{ - semconv.DBSystemNameMySQL, semconv.ServerAddress("example.com"), semconv.DBNamespace("db"), }, @@ -47,7 +45,6 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "mysql://root:otel_password@tcp(example.com:3307)/db?parseTime=true", expected: []attribute.KeyValue{ - semconv.DBSystemNameMySQL, semconv.ServerAddress("example.com"), semconv.ServerPort(3307), semconv.DBNamespace("db"), @@ -56,7 +53,6 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "mysql://root:otel_password@tcp([2001:db8:1234:5678:9abc:def0:0001]:3307)/db?parseTime=true", expected: []attribute.KeyValue{ - semconv.DBSystemNameMySQL, semconv.ServerAddress("2001:db8:1234:5678:9abc:def0:0001"), semconv.ServerPort(3307), semconv.DBNamespace("db"), @@ -65,7 +61,6 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "mysql://root:otel_password@tcp(2001:db8:1234:5678:9abc:def0:0001)/db?parseTime=true", expected: []attribute.KeyValue{ - semconv.DBSystemNameMySQL, semconv.ServerAddress("2001:db8:1234:5678:9abc:def0:0001"), semconv.DBNamespace("db"), }, @@ -73,7 +68,6 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "root:secret@tcp(mysql)/db?parseTime=true", expected: []attribute.KeyValue{ - semconv.DBSystemNameOtherSQL, semconv.ServerAddress("mysql"), semconv.DBNamespace("db"), }, @@ -81,7 +75,6 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "root:secret@tcp(mysql:3307)/db?parseTime=true", expected: []attribute.KeyValue{ - semconv.DBSystemNameOtherSQL, semconv.ServerAddress("mysql"), semconv.ServerPort(3307), semconv.DBNamespace("db"), @@ -90,14 +83,12 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "root:secret@/db?parseTime=true", expected: []attribute.KeyValue{ - semconv.DBSystemNameOtherSQL, semconv.DBNamespace("db"), }, }, { dsn: "example.com/db?parseTime=true", expected: []attribute.KeyValue{ - semconv.DBSystemNameOtherSQL, semconv.ServerAddress("example.com"), semconv.DBNamespace("db"), }, @@ -105,7 +96,6 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "example.com:3307/db?parseTime=true", expected: []attribute.KeyValue{ - semconv.DBSystemNameOtherSQL, semconv.ServerAddress("example.com"), semconv.ServerPort(3307), semconv.DBNamespace("db"), @@ -114,7 +104,6 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "example.com:3307", expected: []attribute.KeyValue{ - semconv.DBSystemNameOtherSQL, semconv.ServerAddress("example.com"), semconv.ServerPort(3307), }, @@ -122,21 +111,18 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "example.com:", expected: []attribute.KeyValue{ - semconv.DBSystemNameOtherSQL, semconv.ServerAddress("example.com"), }, }, { dsn: "example.com", expected: []attribute.KeyValue{ - semconv.DBSystemNameOtherSQL, semconv.ServerAddress("example.com"), }, }, { dsn: "example.com/db", expected: []attribute.KeyValue{ - semconv.DBSystemNameOtherSQL, semconv.ServerAddress("example.com"), semconv.DBNamespace("db"), }, @@ -144,7 +130,6 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "postgres://root:secret@0.0.0.0:42/db?param1=value1¶mN=valueN", expected: []attribute.KeyValue{ - semconv.DBSystemNamePostgreSQL, semconv.ServerAddress("0.0.0.0"), semconv.ServerPort(42), semconv.DBNamespace("db"), @@ -153,7 +138,6 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "postgres://root:secret@2001:db8:1234:5678:9abc:def0:0001/db?param1=value1¶mN=valueN", expected: []attribute.KeyValue{ - semconv.DBSystemNamePostgreSQL, semconv.ServerAddress("2001:db8:1234:5678:9abc:def0:0001"), semconv.DBNamespace("db"), }, @@ -161,7 +145,6 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "postgres://root:secret@[2001:db8:1234:5678:9abc:def0:0001]:42/db?param1=value1¶mN=valueN", expected: []attribute.KeyValue{ - semconv.DBSystemNamePostgreSQL, semconv.ServerAddress("2001:db8:1234:5678:9abc:def0:0001"), semconv.ServerPort(42), semconv.DBNamespace("db"), @@ -170,7 +153,6 @@ func TestAttributesFromDSN(t *testing.T) { { dsn: "root:secret@0.0.0.0:42/db?param1=value1¶mN=valueN", expected: []attribute.KeyValue{ - semconv.DBSystemNameOtherSQL, semconv.ServerAddress("0.0.0.0"), semconv.ServerPort(42), semconv.DBNamespace("db"), @@ -180,7 +162,6 @@ func TestAttributesFromDSN(t *testing.T) { // In this case, "tcp" will be considered as the server address. dsn: "root:secret@tcp/db?param1=value1¶mN=valueN", expected: []attribute.KeyValue{ - semconv.DBSystemNameOtherSQL, semconv.ServerAddress("tcp"), semconv.DBNamespace("db"), }, @@ -189,7 +170,6 @@ func TestAttributesFromDSN(t *testing.T) { // DSN lacking a db-name dsn: "sqlserver://user:pass@dbhost:1433", expected: []attribute.KeyValue{ - semconv.DBSystemNameMicrosoftSQLServer, semconv.ServerAddress("dbhost"), semconv.ServerPort(1433), }, @@ -198,15 +178,12 @@ func TestAttributesFromDSN(t *testing.T) { // DSN lacking a db-name, with trailing '/' dsn: "postgres://user:pass@dbhost/", expected: []attribute.KeyValue{ - semconv.DBSystemNamePostgreSQL, semconv.ServerAddress("dbhost"), }, }, { - // Unrecognized scheme falls back to OtherSQL dsn: "unknown://user:pass@dbhost/db", expected: []attribute.KeyValue{ - semconv.DBSystemNameOtherSQL, semconv.ServerAddress("dbhost"), semconv.DBNamespace("db"), }, @@ -220,53 +197,3 @@ func TestAttributesFromDSN(t *testing.T) { }) } } - -func TestDBSystemFromScheme(t *testing.T) { - testCases := []struct { - scheme string - expected attribute.KeyValue - }{ - {"mysql", semconv.DBSystemNameMySQL}, - {"postgres", semconv.DBSystemNamePostgreSQL}, - {"postgresql", semconv.DBSystemNamePostgreSQL}, - {"sqlserver", semconv.DBSystemNameMicrosoftSQLServer}, - {"mssql", semconv.DBSystemNameMicrosoftSQLServer}, - {"oracle", semconv.DBSystemNameOracleDB}, - {"oracle+cx_oracle", semconv.DBSystemNameOracleDB}, - {"sqlite", semconv.DBSystemNameSqlite}, - {"sqlite3", semconv.DBSystemNameSqlite}, - {"mariadb", semconv.DBSystemNameMariaDB}, - {"cockroachdb", semconv.DBSystemNameCockroachdb}, - {"cockroach", semconv.DBSystemNameCockroachdb}, - {"cassandra", semconv.DBSystemNameCassandra}, - {"redis", semconv.DBSystemNameRedis}, - {"rediss", semconv.DBSystemNameRedis}, - {"mongodb", semconv.DBSystemNameMongoDB}, - {"mongodb+srv", semconv.DBSystemNameMongoDB}, - {"clickhouse", semconv.DBSystemNameClickhouse}, - {"trino", semconv.DBSystemNameTrino}, - {"hive", semconv.DBSystemNameHive}, - {"spanner", semconv.DBSystemNameGCPSpanner}, - {"elasticsearch", semconv.DBSystemNameElasticsearch}, - {"couchbase", semconv.DBSystemNameCouchbase}, - {"influxdb", semconv.DBSystemNameInfluxdb}, - {"dynamodb", semconv.DBSystemNameAWSDynamoDB}, - {"redshift", semconv.DBSystemNameAWSRedshift}, - {"teradata", semconv.DBSystemNameTeradata}, - {"firebird", semconv.DBSystemNameFirebirdsql}, - {"firebirdsql", semconv.DBSystemNameFirebirdsql}, - {"hbase", semconv.DBSystemNameHBase}, - // Case-insensitive - {"MySQL", semconv.DBSystemNameMySQL}, - {"POSTGRES", semconv.DBSystemNamePostgreSQL}, - // Unknown and empty schemes fall back to OtherSQL - {"unknown", semconv.DBSystemNameOtherSQL}, - {"", semconv.DBSystemNameOtherSQL}, - } - - for _, tc := range testCases { - t.Run(tc.scheme, func(t *testing.T) { - assert.Equal(t, tc.expected, dbSystemFromScheme(tc.scheme)) - }) - } -} From 3b8b7286d2e004b0dad1c4ddd3f4e6de3e696f0b Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Tue, 31 Mar 2026 19:31:30 +0200 Subject: [PATCH 08/25] Better commeRemove db.system.name from AttributesFromDSNnts --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a66f2774..829beea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,6 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - `AttributesFromDSN` now extracts the database name from the DSN and sets it as the `db.namespace` attribute ([`semconv.DBNamespaceKey`](https://opentelemetry.io/docs/specs/semconv/database/database-spans/#common-attributes)) and the database system (e.g. `mysql` or `postgresql`) and sets it as the `db.system.name` attribute - ([`semconv.DBSystemNameKey`](https://opentelemetry.io/docs/specs/semconv/database/database-spans/#common-attributes)). ## [0.42.0] - 2026-03-30 From 6d55c88691c9bcc832a1037e05295c853cdebed1 Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Thu, 2 Apr 2026 20:39:28 +0200 Subject: [PATCH 09/25] remove the `scheme` returned value + fix edge case --- helpers.go | 21 ++++++++++++--------- helpers_test.go | 7 +++++++ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/helpers.go b/helpers.go index 57b8d553..85b4c51d 100644 --- a/helpers.go +++ b/helpers.go @@ -27,7 +27,7 @@ import ( // It makes the best effort to retrieve values for // [semconv.ServerAddressKey], [semconv.ServerPortKey], and [semconv.DBNamespaceKey]. func AttributesFromDSN(dsn string) []attribute.KeyValue { - _, serverAddress, serverPort, dbName := parseDSN(dsn) + serverAddress, serverPort, dbName := parseDSN(dsn) var attrs []attribute.KeyValue @@ -46,15 +46,14 @@ func AttributesFromDSN(dsn string) []attribute.KeyValue { return attrs } -// parseDSN parses a DSN string and returns the scheme, server address, server port, and database name. +// parseDSN parses a DSN string and returns the server address, server port, and database name. // It handles the format: [scheme://][user[:password]@][protocol([addr])]/dbname[?params] -// scheme, serverAddress and dbName are empty strings if not found. serverPort is -1 if not found. -func parseDSN(dsn string) (scheme, serverAddress string, serverPort int64, dbName string) { +// serverAddress and dbName are empty strings if not found. serverPort is -1 if not found. +func parseDSN(dsn string) (serverAddress string, serverPort int64, dbName string) { serverPort = -1 // Extract scheme if i := strings.Index(dsn, "://"); i != -1 { - scheme = dsn[:i] dsn = dsn[i+3:] } @@ -76,18 +75,22 @@ func parseDSN(dsn string) (scheme, serverAddress string, serverPort int64, dbNam // Skip protocol if i := strings.Index(dsn, "("); i != -1 { - dsn = dsn[i+1 : len(dsn)-1] + rest := dsn[i+1:] + if j := strings.Index(rest, ")"); j != -1 { + rest = rest[:j] + } + dsn = rest } if len(dsn) == 0 { - return scheme, serverAddress, serverPort, dbName + return serverAddress, serverPort, dbName } // Extract host and port host, portStr, err := net.SplitHostPort(dsn) if err != nil { serverAddress = dsn - return scheme, serverAddress, serverPort, dbName + return serverAddress, serverPort, dbName } serverAddress = host @@ -96,5 +99,5 @@ func parseDSN(dsn string) (scheme, serverAddress string, serverPort int64, dbNam serverPort = port } - return scheme, serverAddress, serverPort, dbName + return serverAddress, serverPort, dbName } diff --git a/helpers_test.go b/helpers_test.go index 7e7b2651..74bc8ed8 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -188,6 +188,13 @@ func TestAttributesFromDSN(t *testing.T) { semconv.DBNamespace("db"), }, }, + { + // malformed DSN shouldn't fail + dsn: "root:pass@tcp(dbhost", + expected: []attribute.KeyValue{ + semconv.ServerAddress("dbhost"), + }, + }, } for _, tc := range testCases { From 1d35f5c3bf0ea322e9eca73e0cd1fb8899b724e0 Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Thu, 2 Apr 2026 20:39:59 +0200 Subject: [PATCH 10/25] Fix CHANGELOG.md --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 829beea9..1429ac16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,6 @@ This is the last release that will support the `OTEL_SEMCONV_STABILITY_OPT_IN` e ### Removed - Drop support for [Go 1.24]. (#611) ->>>>>>> main ## [0.41.0] - 2025-12-16 From 0a00b8e6cc5ed7aa40252e3bd074079f418e027d Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Thu, 2 Apr 2026 20:50:33 +0200 Subject: [PATCH 11/25] Better comments --- helpers.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/helpers.go b/helpers.go index 85b4c51d..6cbaae77 100644 --- a/helpers.go +++ b/helpers.go @@ -52,36 +52,44 @@ func AttributesFromDSN(dsn string) []attribute.KeyValue { func parseDSN(dsn string) (serverAddress string, serverPort int64, dbName string) { serverPort = -1 - // Extract scheme + // [scheme://][user[:password]@][protocol([addr])]/dbname[?param1=value1¶mN=valueN] + // Find the schema part. if i := strings.Index(dsn, "://"); i != -1 { dsn = dsn[i+3:] } - // Skip credentials + // [user[:password]@][protocol([addr])]/dbname[?param1=value1¶mN=valueN] + // Find credentials part. if i := strings.Index(dsn, "@"); i != -1 { dsn = dsn[i+1:] } - // Extract db name + // [protocol([addr])]/dbname[?param1=value1¶mN=valueN] if i := strings.Index(dsn, "/"); i != -1 { path := dsn[i+1:] + // dbname[?param1=value1¶mN=valueN] if j := strings.Index(path, "?"); j != -1 { path = path[:j] } - + // Extract db name dbName = path + // [protocol([addr])] or [addr] dsn = dsn[:i] } + // [protocol([addr])] or [addr] + // Find the '(' that starts the address part. // Skip protocol if i := strings.Index(dsn, "("); i != -1 { rest := dsn[i+1:] if j := strings.Index(rest, ")"); j != -1 { rest = rest[:j] } + // Remove the protocol part from the DSN. dsn = rest } + // [addr] if len(dsn) == 0 { return serverAddress, serverPort, dbName } From 6cdd26f68f81720802b8cf573f745c89c81e6a1a Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Thu, 2 Apr 2026 21:18:55 +0200 Subject: [PATCH 12/25] Revert Makefile change --- Makefile | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/Makefile b/Makefile index b46b884b..3f11f5a5 100644 --- a/Makefile +++ b/Makefile @@ -97,14 +97,8 @@ test-coverage: (cd "$${dir}" && \ $(GO) list ./... \ | grep -v third_party \ - | grep -v internal \ | xargs $(GO) test -coverpkg=./... -covermode=$(COVERAGE_MODE) -coverprofile="$(COVERAGE_PROFILE)" && \ - $(GO) list ./... \ - | grep -v third_party \ - | grep internal \ - | xargs $(GO) test -coverpkg=$$($(GO) list ./... | grep internal | tr '\n' ',') -covermode=$(COVERAGE_MODE) -coverprofile="$(COVERAGE_PROFILE).internal" && \ - grep -v "^mode:" "$(COVERAGE_PROFILE).internal" >> "$(COVERAGE_PROFILE)" && \ - $(GO) tool cover -html=$(COVERAGE_PROFILE) -o coverage.html); \ + $(GO) tool cover -html=coverage.out -o coverage.html); \ [ -f "$${dir}/coverage.out" ] && cat "$${dir}/coverage.out" >> coverage.txt; \ done; \ sed -i.bak -e '2,$$ { /^mode: /d; }' coverage.txt From 8a4279332dbc0b95532ef0fccf1092f222294b91 Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Thu, 2 Apr 2026 23:12:13 +0200 Subject: [PATCH 13/25] Fix CHANGELOG.md --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1429ac16..8b78536b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added - `AttributesFromDSN` now extracts the database name from the DSN and sets it as the `db.namespace` attribute - ([`semconv.DBNamespaceKey`](https://opentelemetry.io/docs/specs/semconv/database/database-spans/#common-attributes)) - and the database system (e.g. `mysql` or `postgresql`) and sets it as the `db.system.name` attribute + ([`semconv.DBNamespaceKey`](https://opentelemetry.io/docs/specs/semconv/database/database-spans/#common-attributes)). [#608](https://github.com/XSAM/otelsql/pull/608) ## [0.42.0] - 2026-03-30 From 70690a710aa9af616f60c5f04efda7c6b0f6a7da Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Fri, 3 Apr 2026 09:40:55 +0200 Subject: [PATCH 14/25] Better align with previous code --- helpers.go | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/helpers.go b/helpers.go index 6cbaae77..2e9e293d 100644 --- a/helpers.go +++ b/helpers.go @@ -54,36 +54,40 @@ func parseDSN(dsn string) (serverAddress string, serverPort int64, dbName string // [scheme://][user[:password]@][protocol([addr])]/dbname[?param1=value1¶mN=valueN] // Find the schema part. - if i := strings.Index(dsn, "://"); i != -1 { - dsn = dsn[i+3:] + schemaIndex := strings.Index(dsn, "://") + if schemaIndex != -1 { + // Remove the schema part from the DSN. + dsn = dsn[schemaIndex+3:] } // [user[:password]@][protocol([addr])]/dbname[?param1=value1¶mN=valueN] // Find credentials part. - if i := strings.Index(dsn, "@"); i != -1 { - dsn = dsn[i+1:] + atIndex := strings.Index(dsn, "@") + if atIndex != -1 { + // Remove the credential part from the DSN. + dsn = dsn[atIndex+1:] } // [protocol([addr])]/dbname[?param1=value1¶mN=valueN] - if i := strings.Index(dsn, "/"); i != -1 { - path := dsn[i+1:] + if pathIndex := strings.Index(dsn, "/"); pathIndex != -1 { + path := dsn[pathIndex+1:] // dbname[?param1=value1¶mN=valueN] - if j := strings.Index(path, "?"); j != -1 { - path = path[:j] + if questionMarkIndex := strings.Index(path, "?"); questionMarkIndex != -1 { + path = path[:questionMarkIndex] } // Extract db name dbName = path // [protocol([addr])] or [addr] - dsn = dsn[:i] + dsn = dsn[:pathIndex] } // [protocol([addr])] or [addr] // Find the '(' that starts the address part. // Skip protocol - if i := strings.Index(dsn, "("); i != -1 { - rest := dsn[i+1:] - if j := strings.Index(rest, ")"); j != -1 { - rest = rest[:j] + if openParen := strings.Index(dsn, "("); openParen != -1 { + rest := dsn[openParen+1:] + if closeParen := strings.Index(rest, ")"); closeParen != -1 { + rest = rest[:closeParen] } // Remove the protocol part from the DSN. dsn = rest From 7add5cc5c1fba4c4204f9b7dcb07b78ebb160eb9 Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Fri, 3 Apr 2026 09:44:44 +0200 Subject: [PATCH 15/25] Better align with previous code --- helpers.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/helpers.go b/helpers.go index 2e9e293d..261685f7 100644 --- a/helpers.go +++ b/helpers.go @@ -69,7 +69,10 @@ func parseDSN(dsn string) (serverAddress string, serverPort int64, dbName string } // [protocol([addr])]/dbname[?param1=value1¶mN=valueN] - if pathIndex := strings.Index(dsn, "/"); pathIndex != -1 { + // Find the '/' that separates the address part from the database part. + pathIndex := strings.Index(dsn, "/") + if pathIndex != -1 { + // Remove the path part from the DSN. path := dsn[pathIndex+1:] // dbname[?param1=value1¶mN=valueN] if questionMarkIndex := strings.Index(path, "?"); questionMarkIndex != -1 { @@ -83,8 +86,9 @@ func parseDSN(dsn string) (serverAddress string, serverPort int64, dbName string // [protocol([addr])] or [addr] // Find the '(' that starts the address part. - // Skip protocol - if openParen := strings.Index(dsn, "("); openParen != -1 { + openParen := strings.Index(dsn, "(") + if openParen != -1 { + // Skip protocol rest := dsn[openParen+1:] if closeParen := strings.Index(rest, ")"); closeParen != -1 { rest = rest[:closeParen] From 1ee7247b322a63225a90e33260a46d98c20295b3 Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Fri, 3 Apr 2026 09:45:34 +0200 Subject: [PATCH 16/25] Better align with previous code --- helpers.go | 1 - 1 file changed, 1 deletion(-) diff --git a/helpers.go b/helpers.go index 261685f7..07481cb1 100644 --- a/helpers.go +++ b/helpers.go @@ -88,7 +88,6 @@ func parseDSN(dsn string) (serverAddress string, serverPort int64, dbName string // Find the '(' that starts the address part. openParen := strings.Index(dsn, "(") if openParen != -1 { - // Skip protocol rest := dsn[openParen+1:] if closeParen := strings.Index(rest, ")"); closeParen != -1 { rest = rest[:closeParen] From e74e5cb2c60204844a5e4593ce99c8ac50c347ec Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Fri, 3 Apr 2026 09:47:36 +0200 Subject: [PATCH 17/25] Better align with previous code --- helpers_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helpers_test.go b/helpers_test.go index 74bc8ed8..1c4b6f56 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -199,8 +199,8 @@ func TestAttributesFromDSN(t *testing.T) { for _, tc := range testCases { t.Run(tc.dsn, func(t *testing.T) { - gotAttrs := AttributesFromDSN(tc.dsn) - assert.Equal(t, tc.expected, gotAttrs) + got := AttributesFromDSN(tc.dsn) + assert.Equal(t, tc.expected, got) }) } } From 7ef7c354c5f50c8ee5dba375da5340a487c32d09 Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Tue, 7 Apr 2026 22:55:11 +0200 Subject: [PATCH 18/25] Add sqlserver DSN support to parseDSN and extend tests parseDSN now extracts the query string before parsing the path, and handles sqlserver DSNs specifically: the path segment is treated as the instance name (not the database name), and db.namespace is read from the "database" query param instead. Also fixes a bug where a bare "host:port?param=val" DSN (no path) would leave the query string attached to the host, causing net.SplitHostPort to drop the port. --- helpers.go | 44 ++++++++++++++++++++++++++++++------------- helpers_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 13 deletions(-) diff --git a/helpers.go b/helpers.go index 7078e943..c98633c6 100644 --- a/helpers.go +++ b/helpers.go @@ -47,20 +47,22 @@ func AttributesFromDSN(dsn string) []attribute.KeyValue { } // parseDSN parses a DSN string and returns the server address, server port, and database name. -// It handles the format: [scheme://][user[:password]@][protocol([addr])]/dbname[?params] +// It handles the format: [scheme://][user[:password]@][protocol([addr])][/dbOrInstanceName][?param1=value1¶mN=valueN] // serverAddress and dbName are empty strings if not found. serverPort is -1 if not found. func parseDSN(dsn string) (serverAddress string, serverPort int64, dbName string) { serverPort = -1 - // [scheme://][user[:password]@][protocol([addr])]/dbname[?param1=value1¶mN=valueN] + // [scheme://][user[:password]@][protocol([addr])][/dbOrInstanceName][?param1=value1¶mN=valueN] // Find the schema part. + var scheme string schemaIndex := strings.Index(dsn, "://") if schemaIndex != -1 { + scheme = dsn[:schemaIndex] // Remove the schema part from the DSN. dsn = dsn[schemaIndex+3:] } - // [user[:password]@][protocol([addr])]/dbname[?param1=value1¶mN=valueN] + // [user[:password]@][protocol([addr])][/dbOrInstanceName][?param1=value1¶mN=valueN] // Find credentials part. atIndex := strings.Index(dsn, "@") if atIndex != -1 { @@ -68,22 +70,38 @@ func parseDSN(dsn string) (serverAddress string, serverPort int64, dbName string dsn = dsn[atIndex+1:] } - // [protocol([addr])]/dbname[?param1=value1¶mN=valueN] - // Find the '/' that separates the address part from the database part. + // [protocol([addr])][/dbOrInstanceName][?param1=value1¶mN=valueN] + // Find the '?' that separates the query string. + var queryString string + if questionMarkIndex := strings.Index(dsn, "?"); questionMarkIndex != -1 { + queryString = dsn[questionMarkIndex+1:] + dsn = dsn[:questionMarkIndex] + } + + // [protocol([addr])][/dbOrInstanceName] + // Find the '/' that separates the address part from the path (database or instance name). pathIndex := strings.Index(dsn, "/") + var path string if pathIndex != -1 { - // Remove the path part from the DSN. - path := dsn[pathIndex+1:] - // dbname[?param1=value1¶mN=valueN] - if questionMarkIndex := strings.Index(path, "?"); questionMarkIndex != -1 { - path = path[:questionMarkIndex] - } - // Extract db name - dbName = path + path = dsn[pathIndex+1:] + // [protocol([addr])] or [addr] dsn = dsn[:pathIndex] } + if scheme == "sqlserver" { + // sqlserver uses the "database" query param; the path is the instance name, not the database. + for _, part := range strings.Split(queryString, "&") { + if kv := strings.SplitN(part, "=", 2); len(kv) == 2 && kv[0] == "database" { + dbName = kv[1] + break + } + } + } else { + // All other drivers encode the database name in the path. + dbName = path + } + // [protocol([addr])] or [addr] // Find the '(' that starts the address part. openParen := strings.Index(dsn, "(") diff --git a/helpers_test.go b/helpers_test.go index c1515009..85d75afb 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -195,6 +195,56 @@ func TestAttributesFromDSN(t *testing.T) { semconv.ServerAddress("dbhost"), }, }, + // MS SQL Server DSNs (go-mssqldb driver) + { + // database name from "database" query param + dsn: "sqlserver://user:pass@dbhost:1433?database=db", + expected: []attribute.KeyValue{ + semconv.ServerAddress("dbhost"), + semconv.ServerPort(1433), + semconv.DBNamespace("db"), + }, + }, + { + // instance name in path, database name from "database" query param + dsn: "sqlserver://user:pass@dbhost/SQLEXPRESS?database=db", + expected: []attribute.KeyValue{ + semconv.ServerAddress("dbhost"), + semconv.DBNamespace("db"), + }, + }, + { + // IPv6 host with port, database name from "database" query param + dsn: "sqlserver://user:pass@[::1]:1433?database=db", + expected: []attribute.KeyValue{ + semconv.ServerAddress("::1"), + semconv.ServerPort(1433), + semconv.DBNamespace("db"), + }, + }, + { + // Windows auth (no credentials), database name from "database" query param + dsn: "sqlserver://dbhost:1433?database=db", + expected: []attribute.KeyValue{ + semconv.ServerAddress("dbhost"), + semconv.ServerPort(1433), + semconv.DBNamespace("db"), + }, + }, + { + // instance name in path, no "database" query param — database name unknown + dsn: "sqlserver://user:pass@dbhost/SQLEXPRESS", + expected: []attribute.KeyValue{ + semconv.ServerAddress("dbhost"), + }, + }, + { + // no path, no "database" query param — database name unknown + dsn: "sqlserver://user:pass@dbhost", + expected: []attribute.KeyValue{ + semconv.ServerAddress("dbhost"), + }, + }, } for _, tc := range testCases { From 77e271887f6c9c779c9b796b9e37d190c099d787 Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Tue, 7 Apr 2026 23:10:37 +0200 Subject: [PATCH 19/25] Simplify parseDSN: use url.ParseQuery for sqlserver database param Replace the manual &-split loop with url.ParseQuery, which always returns a non-nil map even on error, making the err check unnecessary. --- helpers.go | 10 +++++----- helpers_test.go | 1 - utils_test.go | 1 - 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/helpers.go b/helpers.go index c98633c6..a36e5e0c 100644 --- a/helpers.go +++ b/helpers.go @@ -16,6 +16,7 @@ package otelsql import ( "net" + "net/url" "strconv" "strings" @@ -55,6 +56,7 @@ func parseDSN(dsn string) (serverAddress string, serverPort int64, dbName string // [scheme://][user[:password]@][protocol([addr])][/dbOrInstanceName][?param1=value1¶mN=valueN] // Find the schema part. var scheme string + schemaIndex := strings.Index(dsn, "://") if schemaIndex != -1 { scheme = dsn[:schemaIndex] @@ -81,6 +83,7 @@ func parseDSN(dsn string) (serverAddress string, serverPort int64, dbName string // [protocol([addr])][/dbOrInstanceName] // Find the '/' that separates the address part from the path (database or instance name). pathIndex := strings.Index(dsn, "/") + var path string if pathIndex != -1 { path = dsn[pathIndex+1:] @@ -91,11 +94,8 @@ func parseDSN(dsn string) (serverAddress string, serverPort int64, dbName string if scheme == "sqlserver" { // sqlserver uses the "database" query param; the path is the instance name, not the database. - for _, part := range strings.Split(queryString, "&") { - if kv := strings.SplitN(part, "=", 2); len(kv) == 2 && kv[0] == "database" { - dbName = kv[1] - break - } + if params, err := url.ParseQuery(queryString); err == nil { + dbName = params.Get("database") } } else { // All other drivers encode the database name in the path. diff --git a/helpers_test.go b/helpers_test.go index 85d75afb..85231d37 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -22,7 +22,6 @@ import ( semconv "go.opentelemetry.io/otel/semconv/v1.40.0" ) -//nolint:gosec func TestAttributesFromDSN(t *testing.T) { testCases := []struct { dsn string diff --git a/utils_test.go b/utils_test.go index 90b5c31b..5fd59b59 100644 --- a/utils_test.go +++ b/utils_test.go @@ -250,7 +250,6 @@ func prepareMetrics() (sdkmetric.Reader, *sdkmetric.MeterProvider) { func getDummyAttributesGetter() AttributesGetter { return func(_ context.Context, method Method, query string, args []driver.NamedValue) []attribute.KeyValue { - //nolint:prealloc attrs := []attribute.KeyValue{ attribute.String("method", string(method)), attribute.String("query", query), From a9ff19a4ada11578c73123a0ed7322e11384a71b Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Tue, 7 Apr 2026 23:16:41 +0200 Subject: [PATCH 20/25] restore "nolint" --- helpers_test.go | 1 + utils_test.go | 1 + 2 files changed, 2 insertions(+) diff --git a/helpers_test.go b/helpers_test.go index 85231d37..85d75afb 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -22,6 +22,7 @@ import ( semconv "go.opentelemetry.io/otel/semconv/v1.40.0" ) +//nolint:gosec func TestAttributesFromDSN(t *testing.T) { testCases := []struct { dsn string diff --git a/utils_test.go b/utils_test.go index 5fd59b59..90c593bb 100644 --- a/utils_test.go +++ b/utils_test.go @@ -248,6 +248,7 @@ func prepareMetrics() (sdkmetric.Reader, *sdkmetric.MeterProvider) { return metricReader, meterProvider } +//nolint:gosec func getDummyAttributesGetter() AttributesGetter { return func(_ context.Context, method Method, query string, args []driver.NamedValue) []attribute.KeyValue { attrs := []attribute.KeyValue{ From ae95af26d4d7e3173652ee39a5b4992238b7c80a Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Mon, 13 Apr 2026 09:26:00 +0200 Subject: [PATCH 21/25] Restrict db.namespace extraction to known DSN schemes and reduce complexity - Extract parseHostPort() helper so parseDSN cyclomatic complexity drops from 13 to 8 (limit is 10) - Replace the catch-all else branch with an explicit switch: db.namespace is extracted from the URL path only for "postgresql", "postgres", "mysql", and "clickhouse" schemes; "sqlserver"/"mssql" continue to use the ?database= query param; all other schemes and scheme-less DSNs produce no db.namespace - Update test expectations accordingly --- helpers.go | 47 ++++++++++++++++++++++++++--------------------- helpers_test.go | 25 +++++++++++-------------- utils_test.go | 1 - 3 files changed, 37 insertions(+), 36 deletions(-) diff --git a/helpers.go b/helpers.go index a36e5e0c..230e9502 100644 --- a/helpers.go +++ b/helpers.go @@ -48,12 +48,10 @@ func AttributesFromDSN(dsn string) []attribute.KeyValue { } // parseDSN parses a DSN string and returns the server address, server port, and database name. -// It handles the format: [scheme://][user[:password]@][protocol([addr])][/dbOrInstanceName][?param1=value1¶mN=valueN] +// It handles the format: [scheme://][user[:password]@][protocol([addr])][/path][?param1=value1¶mN=valueN] // serverAddress and dbName are empty strings if not found. serverPort is -1 if not found. func parseDSN(dsn string) (serverAddress string, serverPort int64, dbName string) { - serverPort = -1 - - // [scheme://][user[:password]@][protocol([addr])][/dbOrInstanceName][?param1=value1¶mN=valueN] + // [scheme://][user[:password]@][protocol([addr])][/path][?param1=value1¶mN=valueN] // Find the schema part. var scheme string @@ -64,7 +62,7 @@ func parseDSN(dsn string) (serverAddress string, serverPort int64, dbName string dsn = dsn[schemaIndex+3:] } - // [user[:password]@][protocol([addr])][/dbOrInstanceName][?param1=value1¶mN=valueN] + // [user[:password]@][protocol([addr])][/path][?param1=value1¶mN=valueN] // Find credentials part. atIndex := strings.Index(dsn, "@") if atIndex != -1 { @@ -72,7 +70,7 @@ func parseDSN(dsn string) (serverAddress string, serverPort int64, dbName string dsn = dsn[atIndex+1:] } - // [protocol([addr])][/dbOrInstanceName][?param1=value1¶mN=valueN] + // [protocol([addr])][/path][?param1=value1¶mN=valueN] // Find the '?' that separates the query string. var queryString string if questionMarkIndex := strings.Index(dsn, "?"); questionMarkIndex != -1 { @@ -80,7 +78,7 @@ func parseDSN(dsn string) (serverAddress string, serverPort int64, dbName string dsn = dsn[:questionMarkIndex] } - // [protocol([addr])][/dbOrInstanceName] + // [protocol([addr])][/path] // Find the '/' that separates the address part from the path (database or instance name). pathIndex := strings.Index(dsn, "/") @@ -92,38 +90,45 @@ func parseDSN(dsn string) (serverAddress string, serverPort int64, dbName string dsn = dsn[:pathIndex] } - if scheme == "sqlserver" { + switch scheme { + case "sqlserver", "mssql": // sqlserver uses the "database" query param; the path is the instance name, not the database. if params, err := url.ParseQuery(queryString); err == nil { dbName = params.Get("database") } - } else { - // All other drivers encode the database name in the path. + case "postgresql", "postgres", "mysql", "clickhouse": dbName = path } - // [protocol([addr])] or [addr] - // Find the '(' that starts the address part. - openParen := strings.Index(dsn, "(") - if openParen != -1 { + // [protocol([addr])] + serverAddress, serverPort = parseHostPort(dsn) + + return serverAddress, serverPort, dbName +} + +// parseHostPort extracts the server address and port from a DSN fragment. +// It handles MySQL's protocol(addr) syntax where the address is wrapped in parentheses. +// serverAddress is an empty string if not found; serverPort is -1 if not found. +func parseHostPort(dsn string) (serverAddress string, serverPort int64) { + serverPort = -1 + + // Strip MySQL's protocol(addr) wrapper, e.g. "tcp(host:3306)" → "host:3306". + if openParen := strings.Index(dsn, "("); openParen != -1 { rest := dsn[openParen+1:] if closeParen := strings.Index(rest, ")"); closeParen != -1 { rest = rest[:closeParen] } - // Remove the protocol part from the DSN. + dsn = rest } - // [addr] if len(dsn) == 0 { - return serverAddress, serverPort, dbName + return } - // Extract host and port host, portStr, err := net.SplitHostPort(dsn) if err != nil { - serverAddress = dsn - return serverAddress, serverPort, dbName + return dsn, serverPort } serverAddress = host @@ -132,5 +137,5 @@ func parseDSN(dsn string) (serverAddress string, serverPort int64, dbName string serverPort = port } - return serverAddress, serverPort, dbName + return } diff --git a/helpers_test.go b/helpers_test.go index 85d75afb..7e306fdd 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -22,7 +22,6 @@ import ( semconv "go.opentelemetry.io/otel/semconv/v1.40.0" ) -//nolint:gosec func TestAttributesFromDSN(t *testing.T) { testCases := []struct { dsn string @@ -66,39 +65,38 @@ func TestAttributesFromDSN(t *testing.T) { }, }, { + // no scheme: db.namespace not extracted dsn: "root:secret@tcp(mysql)/db?parseTime=true", expected: []attribute.KeyValue{ semconv.ServerAddress("mysql"), - semconv.DBNamespace("db"), }, }, { + // no scheme: db.namespace not extracted dsn: "root:secret@tcp(mysql:3307)/db?parseTime=true", expected: []attribute.KeyValue{ semconv.ServerAddress("mysql"), semconv.ServerPort(3307), - semconv.DBNamespace("db"), }, }, { - dsn: "root:secret@/db?parseTime=true", - expected: []attribute.KeyValue{ - semconv.DBNamespace("db"), - }, + // no scheme: db.namespace not extracted + dsn: "root:secret@/db?parseTime=true", + expected: nil, }, { + // no scheme: db.namespace not extracted dsn: "example.com/db?parseTime=true", expected: []attribute.KeyValue{ semconv.ServerAddress("example.com"), - semconv.DBNamespace("db"), }, }, { + // no scheme: db.namespace not extracted dsn: "example.com:3307/db?parseTime=true", expected: []attribute.KeyValue{ semconv.ServerAddress("example.com"), semconv.ServerPort(3307), - semconv.DBNamespace("db"), }, }, { @@ -121,10 +119,10 @@ func TestAttributesFromDSN(t *testing.T) { }, }, { + // no scheme: db.namespace not extracted dsn: "example.com/db", expected: []attribute.KeyValue{ semconv.ServerAddress("example.com"), - semconv.DBNamespace("db"), }, }, { @@ -151,19 +149,18 @@ func TestAttributesFromDSN(t *testing.T) { }, }, { + // no scheme: db.namespace not extracted dsn: "root:secret@0.0.0.0:42/db?param1=value1¶mN=valueN", expected: []attribute.KeyValue{ semconv.ServerAddress("0.0.0.0"), semconv.ServerPort(42), - semconv.DBNamespace("db"), }, }, { - // In this case, "tcp" will be considered as the server address. + // no scheme: db.namespace not extracted; "tcp" is considered as the server address dsn: "root:secret@tcp/db?param1=value1¶mN=valueN", expected: []attribute.KeyValue{ semconv.ServerAddress("tcp"), - semconv.DBNamespace("db"), }, }, { @@ -182,10 +179,10 @@ func TestAttributesFromDSN(t *testing.T) { }, }, { + // unknown scheme: db.namespace not extracted dsn: "unknown://user:pass@dbhost/db", expected: []attribute.KeyValue{ semconv.ServerAddress("dbhost"), - semconv.DBNamespace("db"), }, }, { diff --git a/utils_test.go b/utils_test.go index 90c593bb..5fd59b59 100644 --- a/utils_test.go +++ b/utils_test.go @@ -248,7 +248,6 @@ func prepareMetrics() (sdkmetric.Reader, *sdkmetric.MeterProvider) { return metricReader, meterProvider } -//nolint:gosec func getDummyAttributesGetter() AttributesGetter { return func(_ context.Context, method Method, query string, args []driver.NamedValue) []attribute.KeyValue { attrs := []attribute.KeyValue{ From 05e3bdef0c524e76385a5b4614bae06e78dbaf1d Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Mon, 13 Apr 2026 09:32:35 +0200 Subject: [PATCH 22/25] Restore nolint:gosec on TestAttributesFromDSN --- helpers_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/helpers_test.go b/helpers_test.go index 7e306fdd..18369c37 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -22,6 +22,7 @@ import ( semconv "go.opentelemetry.io/otel/semconv/v1.40.0" ) +//nolint:gosec func TestAttributesFromDSN(t *testing.T) { testCases := []struct { dsn string From 1e56f4f27710cf55599ff0e12d03e1f871098f53 Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Mon, 13 Apr 2026 09:39:09 +0200 Subject: [PATCH 23/25] Update CHANGELOG and add test for malformed DSN - Document supported schemes and bug fixes in [Unreleased] - Add test case for missing closing parenthesis with no path/query string --- CHANGELOG.md | 10 ++++++++-- helpers_test.go | 7 +++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff2a5f61..0cd41432 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,14 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added -- `AttributesFromDSN` now extracts the database name from the DSN and sets it as the `db.namespace` attribute - ([`semconv.DBNamespaceKey`](https://opentelemetry.io/docs/specs/semconv/database/database-spans/#common-attributes)). [#608](https://github.com/XSAM/otelsql/pull/608) +- `AttributesFromDSN` now extracts the database name from the DSN and sets it as the `db.namespace` attribute + ([`semconv.DBNamespaceKey`](https://opentelemetry.io/docs/specs/semconv/database/database-spans/#common-attributes)). + Supported schemes: `postgresql`, `postgres`, `mysql`, `clickhouse`, `sqlserver`, `mssql`. [#608](https://github.com/XSAM/otelsql/pull/608) + +### Fixed + +- Fix panic in `AttributesFromDSN` when the closing protocol parenthesis is missing and the DSN has no path nor query string. [#608](https://github.com/XSAM/otelsql/pull/608) +- Fix `server.port` parsing in `AttributesFromDSN` when the DSN has no path but has a query string. [#608](https://github.com/XSAM/otelsql/pull/608) ### Changed diff --git a/helpers_test.go b/helpers_test.go index 18369c37..9e5e4d53 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -243,6 +243,13 @@ func TestAttributesFromDSN(t *testing.T) { semconv.ServerAddress("dbhost"), }, }, + { + // Missing closing protocol parenthesis and no path/queryString shouldn't cause a panic + dsn: "mysql://root:otel_password@tcp(example.com", + expected: []attribute.KeyValue{ + semconv.ServerAddress("example.com"), + }, + }, } for _, tc := range testCases { From 02329571447aa148096e1dfe72d669252b56337f Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Mon, 13 Apr 2026 09:47:26 +0200 Subject: [PATCH 24/25] Preallocate attrs slice in getDummyAttributesGetter --- utils_test.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/utils_test.go b/utils_test.go index 5fd59b59..a7390809 100644 --- a/utils_test.go +++ b/utils_test.go @@ -250,10 +250,9 @@ func prepareMetrics() (sdkmetric.Reader, *sdkmetric.MeterProvider) { func getDummyAttributesGetter() AttributesGetter { return func(_ context.Context, method Method, query string, args []driver.NamedValue) []attribute.KeyValue { - attrs := []attribute.KeyValue{ - attribute.String("method", string(method)), - attribute.String("query", query), - } + attrs := make([]attribute.KeyValue, 2, 2+len(args)) + attrs[0] = attribute.String("method", string(method)) + attrs[1] = attribute.String("query", query) for i, a := range args { attrs = append(attrs, attribute.String( From c17a842740ec5111a53e48a119923f1eb1ef0546 Mon Sep 17 00:00:00 2001 From: Cyrille Le Clerc Date: Mon, 13 Apr 2026 09:48:49 +0200 Subject: [PATCH 25/25] Consolidate Fixed section in CHANGELOG [Unreleased] --- CHANGELOG.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b15c2816..367adcbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,6 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ([`semconv.DBNamespaceKey`](https://opentelemetry.io/docs/specs/semconv/database/database-spans/#common-attributes)). Supported schemes: `postgresql`, `postgres`, `mysql`, `clickhouse`, `sqlserver`, `mssql`. [#608](https://github.com/XSAM/otelsql/pull/608) -### Fixed - -- Fix panic in `AttributesFromDSN` when the closing protocol parenthesis is missing and the DSN has no path nor query string. [#608](https://github.com/XSAM/otelsql/pull/608) -- Fix `server.port` parsing in `AttributesFromDSN` when the DSN has no path but has a query string. [#608](https://github.com/XSAM/otelsql/pull/608) - ### Changed - Upgrade OTel Semconv to `v1.40.0`. (#606) @@ -26,6 +21,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Fixed - Implement `driver.Validator` on `otConn` so that `database/sql` connection pool health checks are properly delegated to the underlying driver connection. (#619) +- Fix panic in `AttributesFromDSN` when the closing protocol parenthesis is missing and the DSN has no path nor query string. [#608](https://github.com/XSAM/otelsql/pull/608) +- Fix `server.port` parsing in `AttributesFromDSN` when the DSN has no path but has a query string. [#608](https://github.com/XSAM/otelsql/pull/608) ## [0.42.0] - 2026-03-30