Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
093ef23
Extract db.namespace from DSN in AttributesFromDSN
cyrille-leclerc Mar 25, 2026
6fabdca
Extract db.namespace from DSN in AttributesFromDSN
cyrille-leclerc Mar 25, 2026
e4d5e0e
Better code coverage through unit tests
cyrille-leclerc Mar 26, 2026
ae48eda
Fix test-coverage goal
cyrille-leclerc Mar 26, 2026
0115bb2
update changelog.md
cyrille-leclerc Mar 26, 2026
04afb00
Merge branch 'main' into extract-db-namespace-from-dsn-2
cyrille-leclerc Mar 31, 2026
3582ac6
Better comments
cyrille-leclerc Mar 31, 2026
dce7fbd
Remove db.system.name from AttributesFromDSN
cyrille-leclerc Mar 31, 2026
3b8b728
Better commeRemove db.system.name from AttributesFromDSNnts
cyrille-leclerc Mar 31, 2026
6d55c88
remove the `scheme` returned value + fix edge case
cyrille-leclerc Apr 2, 2026
1d35f5c
Fix CHANGELOG.md
cyrille-leclerc Apr 2, 2026
0a00b8e
Better comments
cyrille-leclerc Apr 2, 2026
6cdd26f
Revert Makefile change
cyrille-leclerc Apr 2, 2026
8a42793
Fix CHANGELOG.md
cyrille-leclerc Apr 2, 2026
70690a7
Better align with previous code
cyrille-leclerc Apr 3, 2026
7add5cc
Better align with previous code
cyrille-leclerc Apr 3, 2026
1ee7247
Better align with previous code
cyrille-leclerc Apr 3, 2026
e74e5cb
Better align with previous code
cyrille-leclerc Apr 3, 2026
55c61f5
Merge branch 'main' into extract-db-namespace-from-dsn-2
cyrille-leclerc Apr 7, 2026
7ef7c35
Add sqlserver DSN support to parseDSN and extend tests
cyrille-leclerc Apr 7, 2026
77e2718
Simplify parseDSN: use url.ParseQuery for sqlserver database param
cyrille-leclerc Apr 7, 2026
a9ff19a
restore "nolint"
cyrille-leclerc Apr 7, 2026
ae95af2
Restrict db.namespace extraction to known DSN schemes and reduce comp…
cyrille-leclerc Apr 13, 2026
05e3bde
Restore nolint:gosec on TestAttributesFromDSN
cyrille-leclerc Apr 13, 2026
1e56f4f
Update CHANGELOG and add test for malformed DSN
cyrille-leclerc Apr 13, 2026
a56b1cc
Merge branch 'main' into extract-db-namespace-from-dsn-2
cyrille-leclerc Apr 13, 2026
0232957
Preallocate attrs slice in getDummyAttributesGetter
cyrille-leclerc Apr 13, 2026
c17a842
Consolidate Fixed section in CHANGELOG [Unreleased]
cyrille-leclerc Apr 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,21 @@ 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)

### 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

Expand Down
107 changes: 82 additions & 25 deletions helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package otelsql

import (
"net"
"net/url"
"strconv"
"strings"

Expand All @@ -24,61 +25,117 @@ 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&paramN=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&paramN=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&paramN=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&paramN=valueN]
// [user[:password]@][protocol([addr])][/path][?param1=value1&paramN=valueN]
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We see with sqlserver and oracle that the /path is not always the dbname

// Find credentials part.
atIndex := strings.Index(dsn, "@")
if atIndex != -1 {
// Remove the credential part from the DSN.
dsn = dsn[atIndex+1:]
}

// [protocol([addr])]/dbname[?param1=value1&paramN=valueN]
// Find the '/' that separates the address part from the database part.
// [protocol([addr])][/path][?param1=value1&paramN=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
}
115 changes: 114 additions & 1 deletion helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,56 +28,72 @@ 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"),
},
},
{
dsn: "mysql://root:otel_password@tcp(example.com:3307)/db?parseTime=true",
expected: []attribute.KeyValue{
semconv.ServerAddress("example.com"),
semconv.ServerPort(3307),
semconv.DBNamespace("db"),
},
},
{
dsn: "mysql://root:otel_password@tcp([2001:db8:1234:5678:9abc:def0:0001]:3307)/db?parseTime=true",
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"),
semconv.ServerPort(3307),
},
},
{
// 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"),
Expand All @@ -103,40 +119,137 @@ 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&paramN=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&paramN=valueN",
expected: []attribute.KeyValue{
semconv.ServerAddress("2001:db8:1234:5678:9abc:def0:0001"),
semconv.DBNamespace("db"),
},
},
{
dsn: "postgres://root:secret@[2001:db8:1234:5678:9abc:def0:0001]:42/db?param1=value1&paramN=valueN",
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&paramN=valueN",
expected: []attribute.KeyValue{
semconv.ServerAddress("0.0.0.0"),
semconv.ServerPort(42),
},
},
{
// 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&paramN=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 {
Expand Down
8 changes: 3 additions & 5 deletions utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Running locally make command removes //nolint:prealloc and breaks CI so I preferred to fix the root cause.

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(
Expand Down
Loading