diff --git a/CHANGELOG.md b/CHANGELOG.md index d0b84813..367adcbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ 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)). + Supported schemes: `postgresql`, `postgres`, `mysql`, `clickhouse`, `sqlserver`, `mssql`. [#608](https://github.com/XSAM/otelsql/pull/608) + ### Changed - Upgrade OTel Semconv to `v1.40.0`. (#606) @@ -15,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 diff --git a/helpers.go b/helpers.go index 36582292..230e9502 100644 --- a/helpers.go +++ b/helpers.go @@ -16,6 +16,7 @@ package otelsql import ( "net" + "net/url" "strconv" "strings" @@ -24,17 +25,44 @@ 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] + 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 server address, server port, and database name. +// 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) { + // [scheme://][user[:password]@][protocol([addr])][/path][?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])][/path][?param1=value1¶mN=valueN] // Find credentials part. atIndex := strings.Index(dsn, "@") if atIndex != -1 { @@ -42,43 +70,72 @@ func AttributesFromDSN(dsn string) []attribute.KeyValue { dsn = dsn[atIndex+1:] } - // [protocol([addr])]/dbname[?param1=value1¶mN=valueN] - // Find the '/' that separates the address part from the database part. + // [protocol([addr])][/path][?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])][/path] + // 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:] + + // [protocol([addr])] or [addr] dsn = dsn[:pathIndex] } - // [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] + 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") + } + case "postgresql", "postgres", "mysql", "clickhouse": + dbName = path + } + + // [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] + } + + dsn = rest } - // [addr] if len(dsn) == 0 { - return nil + return } host, portStr, err := net.SplitHostPort(dsn) if err != nil { - host = dsn + return dsn, serverPort } - 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 } diff --git a/helpers_test.go b/helpers_test.go index a8f549e0..9e5e4d53 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -28,10 +28,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"), }, }, { @@ -39,6 +47,7 @@ func TestAttributesFromDSN(t *testing.T) { expected: []attribute.KeyValue{ semconv.ServerAddress("example.com"), semconv.ServerPort(3307), + semconv.DBNamespace("db"), }, }, { @@ -46,21 +55,25 @@ 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"), }, }, { + // no scheme: db.namespace not extracted dsn: "root:secret@tcp(mysql)/db?parseTime=true", expected: []attribute.KeyValue{ semconv.ServerAddress("mysql"), }, }, { + // no scheme: db.namespace not extracted dsn: "root:secret@tcp(mysql:3307)/db?parseTime=true", expected: []attribute.KeyValue{ semconv.ServerAddress("mysql"), @@ -68,16 +81,19 @@ func TestAttributesFromDSN(t *testing.T) { }, }, { + // 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"), }, }, { + // no scheme: db.namespace not extracted dsn: "example.com:3307/db?parseTime=true", expected: []attribute.KeyValue{ semconv.ServerAddress("example.com"), @@ -103,17 +119,26 @@ func TestAttributesFromDSN(t *testing.T) { semconv.ServerAddress("example.com"), }, }, + { + // no scheme: db.namespace not extracted + dsn: "example.com/db", + expected: []attribute.KeyValue{ + semconv.ServerAddress("example.com"), + }, + }, { 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"), }, }, { @@ -121,9 +146,11 @@ func TestAttributesFromDSN(t *testing.T) { expected: []attribute.KeyValue{ semconv.ServerAddress("2001:db8:1234:5678:9abc:def0:0001"), semconv.ServerPort(42), + semconv.DBNamespace("db"), }, }, { + // 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"), @@ -131,12 +158,98 @@ func TestAttributesFromDSN(t *testing.T) { }, }, { - // 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"), }, }, + { + // 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"), + }, + }, + { + // unknown scheme: db.namespace not extracted + dsn: "unknown://user:pass@dbhost/db", + expected: []attribute.KeyValue{ + semconv.ServerAddress("dbhost"), + }, + }, + { + // malformed DSN shouldn't fail + dsn: "root:pass@tcp(dbhost", + expected: []attribute.KeyValue{ + 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"), + }, + }, + { + // 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 { diff --git a/utils_test.go b/utils_test.go index 90b5c31b..a7390809 100644 --- a/utils_test.go +++ b/utils_test.go @@ -250,11 +250,9 @@ 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), - } + 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(