diff --git a/.chloggen/sqlserver-receiver-host-extraction.yaml b/.chloggen/sqlserver-receiver-host-extraction.yaml new file mode 100644 index 0000000000000..72923596ac906 --- /dev/null +++ b/.chloggen/sqlserver-receiver-host-extraction.yaml @@ -0,0 +1,29 @@ +# Use this changelog template to create an entry for release notes. + +# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix' +change_type: bug_fix + +# The name of the component, or a single word describing the area of concern, (e.g. receiver/filelog) +component: receiver/sqlserver + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: "Fixed issue where Host variable was not being set by msdsn.Parse() in certain connection string formats. Added fallback mechanism to manually extract host from connection strings when the primary parser fails to set the Host field." + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [42355] + +# (Optional) One or more lines of additional information to render under the primary note. +# These lines will be padded with 2 spaces and then inserted directly into the document. +# Use pipe (|) for multiline entries. +subtext: | + The parseDataSource() function now checks if the Host field is empty after parsing and falls back to manual extraction using extractHostFromDataSource(). This handles cases where msdsn.Parse() from the go-mssqldb library doesn't properly set the Host field for certain DSN formats. The fallback supports URL-style (sqlserver://...), ADO-style (server=...), and ODBC-style connection strings. + +# If your change doesn't affect end users or the exported elements of any package, +# you should instead start your pull request title with [chore] or use the "Skip Changelog" label. +# Optional: The change log or logs in which this entry should be included. +# e.g. '[user]' or '[user, api]' +# Include 'user' if the change is relevant to end users. +# Include 'api' if there is a change to a library API. +# Default: '[user]' +change_logs: [user] + diff --git a/receiver/sqlserverreceiver/service_instance_id.go b/receiver/sqlserverreceiver/service_instance_id.go index 2761318aed819..2995974c141d7 100644 --- a/receiver/sqlserverreceiver/service_instance_id.go +++ b/receiver/sqlserverreceiver/service_instance_id.go @@ -67,6 +67,7 @@ func computeServiceInstanceID(cfg *Config) (string, error) { // parseDataSource extracts server and port from SQL Server connection string // Uses the microsoft/go-mssqldb library's built-in parser for accurate parsing +// Falls back to manual extraction if the Host field is not set by the parser func parseDataSource(dataSource string) (string, int, error) { if dataSource == "" { return "", 0, errors.New("datasource is empty") @@ -78,11 +79,91 @@ func parseDataSource(dataSource string) (string, int, error) { return "", 0, fmt.Errorf("failed to parse datasource: %w", err) } + host := config.Host + + // Fallback: if Host is not set by the parser, extract it manually + if host == "" { + host = extractHostFromDataSource(dataSource) + } + // Apply default port if not specified port := int(config.Port) if port == 0 { port = defaultSQLServerPort } - return config.Host, port, nil + return host, port, nil +} + +// extractHostFromDataSource manually extracts the host/server from various DSN formats +// Handles ADO-style, ODBC-style, and URL-style connection strings +func extractHostFromDataSource(dataSource string) string { + // Try URL format first (sqlserver://user:password@host:port) + if strings.Contains(dataSource, "://") { + parts := strings.SplitN(dataSource, "://", 2) + if len(parts) == 2 { + // Extract authority part (everything before next /) + authority := parts[1] + if idx := strings.Index(authority, "/"); idx != -1 { + authority = authority[:idx] + } + // Remove user:password@ prefix + if idx := strings.LastIndex(authority, "@"); idx != -1 { + authority = authority[idx+1:] + } + // Remove port suffix if present + if idx := strings.LastIndex(authority, ":"); idx != -1 { + // Check if what follows is a port (digits) + potential := authority[idx+1:] + if len(potential) > 0 && isNumeric(potential) { + authority = authority[:idx] + } + } + if authority != "" { + return authority + } + } + } + + // Try ADO/ODBC format (key=value;key=value) + // Look for 'server', 'data source', 'address', or 'network address' keys + parts := strings.Split(dataSource, ";") + for _, part := range parts { + kv := strings.SplitN(part, "=", 2) + if len(kv) == 2 { + key := strings.TrimSpace(strings.ToLower(kv[0])) + value := strings.TrimSpace(kv[1]) + + // Remove quotes if present + if len(value) >= 2 && strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") { + value = value[1 : len(value)-1] + } + + if key == "server" || key == "data source" || key == "address" || key == "network address" { + // Extract just the hostname (without port) + if idx := strings.LastIndex(value, ":"); idx != -1 { + // Check if what follows is a port (digits) + potential := value[idx+1:] + if len(potential) > 0 && isNumeric(potential) { + value = value[:idx] + } + } + if value != "" { + return value + } + } + } + } + + return "" +} + +// isNumeric checks if a string contains only digits +func isNumeric(s string) bool { + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return len(s) > 0 } diff --git a/receiver/sqlserverreceiver/service_instance_id_test.go b/receiver/sqlserverreceiver/service_instance_id_test.go index d1f611992e34e..06c2945699341 100644 --- a/receiver/sqlserverreceiver/service_instance_id_test.go +++ b/receiver/sqlserverreceiver/service_instance_id_test.go @@ -253,3 +253,326 @@ func TestParseDataSource(t *testing.T) { }) } } + +// TestIsLocalhost tests the isLocalhost function +func TestIsLocalhost(t *testing.T) { + tests := []struct { + name string + host string + expected bool + }{ + { + name: "localhost lowercase", + host: "localhost", + expected: true, + }, + { + name: "localhost uppercase", + host: "LOCALHOST", + expected: true, + }, + { + name: "localhost mixed case", + host: "LocalHost", + expected: true, + }, + { + name: "127.0.0.1", + host: "127.0.0.1", + expected: true, + }, + { + name: "127.0.0.2", + host: "127.0.0.2", + expected: true, + }, + { + name: "::1 (IPv6 loopback)", + host: "::1", + expected: true, + }, + { + name: "remote host", + host: "sqlserver.example.com", + expected: false, + }, + { + name: "IP address", + host: "192.168.1.1", + expected: false, + }, + { + name: "empty string", + host: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isLocalhost(tt.host) + assert.Equal(t, tt.expected, result) + }) + } +} + +// TestIsNumeric tests the isNumeric helper function +func TestIsNumeric(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + { + name: "valid port number", + input: "1433", + expected: true, + }, + { + name: "zero", + input: "0", + expected: true, + }, + { + name: "large number", + input: "65535", + expected: true, + }, + { + name: "with letters", + input: "1433abc", + expected: false, + }, + { + name: "with special characters", + input: "14-33", + expected: false, + }, + { + name: "empty string", + input: "", + expected: false, + }, + { + name: "space", + input: " ", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isNumeric(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +// TestExtractHostFromDataSource tests the extractHostFromDataSource function +// This tests the fallback mechanism when msdsn.Parse() doesn't set Host properly +func TestExtractHostFromDataSource(t *testing.T) { + tests := []struct { + name string + dsn string + expected string + }{ + // URL format tests + { + name: "URL format with host only", + dsn: "sqlserver://localhost", + expected: "localhost", + }, + { + name: "URL format with host and port", + dsn: "sqlserver://localhost:1433", + expected: "localhost", + }, + { + name: "URL format with user and password", + dsn: "sqlserver://sa:password@localhost", + expected: "localhost", + }, + { + name: "URL format with user, password, host, and port", + dsn: "sqlserver://sa:password@localhost:1433", + expected: "localhost", + }, + { + name: "URL format with IP address", + dsn: "sqlserver://sa:password@192.168.1.100:1433", + expected: "192.168.1.100", + }, + { + name: "URL format with database path", + dsn: "sqlserver://sa:password@localhost:1433/mydb", + expected: "localhost", + }, + { + name: "URL format with complex password", + dsn: "sqlserver://sa:p@ss%40word@sqlserver.example.com:1433", + expected: "sqlserver.example.com", + }, + // ADO/ODBC format tests + { + name: "ADO format with server", + dsn: "server=localhost;user id=sa;password=password", + expected: "localhost", + }, + { + name: "ADO format with data source", + dsn: "data source=localhost;user id=sa;password=password", + expected: "localhost", + }, + { + name: "ADO format with address", + dsn: "address=localhost;user id=sa;password=password", + expected: "localhost", + }, + { + name: "ADO format with network address", + dsn: "network address=localhost;user id=sa;password=password", + expected: "localhost", + }, + { + name: "ADO format with server and port", + dsn: "server=localhost:1433;user id=sa;password=password", + expected: "localhost", + }, + { + name: "ADO format with quoted values", + dsn: `server="localhost";user id="sa";password="password"`, + expected: "localhost", + }, + { + name: "ADO format case insensitive", + dsn: "SERVER=localhost;USER ID=sa;PASSWORD=password", + expected: "localhost", + }, + { + name: "ADO format mixed case", + dsn: "Server=myserver.example.com;User Id=sa;Password=password", + expected: "myserver.example.com", + }, + { + name: "ADO format with IP address", + dsn: "server=192.168.1.100:1433;uid=sa;pwd=password", + expected: "192.168.1.100", + }, + { + name: "ADO format with spaces in keys", + dsn: "data source=localhost; user id=sa; password=password", + expected: "localhost", + }, + { + name: "ADO format with FQDN", + dsn: "Server=mydb.company.com:1500;User Id=sa;Password=password", + expected: "mydb.company.com", + }, + // Edge cases + { + name: "empty string", + dsn: "", + expected: "", + }, + { + name: "only server key", + dsn: "server=localhost", + expected: "localhost", + }, + { + name: "no recognized server keys", + dsn: "user id=sa;password=password", + expected: "", + }, + { + name: "named instance with server key", + dsn: "server=localhost\\SQLEXPRESS", + expected: "localhost\\SQLEXPRESS", // Fallback preserves the full server value + }, + { + name: "port as comma-separated in server value", + dsn: "server=localhost,5000;user id=sa", + expected: "localhost,5000", // Fallback preserves comma-separated format + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractHostFromDataSource(tt.dsn) + assert.Equal(t, tt.expected, result) + }) + } +} + +// TestParseDataSourceFallbackMechanism tests that fallback extraction works when msdsn doesn't set Host +func TestParseDataSourceFallbackMechanism(t *testing.T) { + tests := []struct { + name string + dataSource string + expectedHost string + expectedPort int + }{ + { + name: "fallback when only ADO-style keys provided", + dataSource: "server=localhost;uid=sa", + expectedHost: "localhost", + expectedPort: defaultSQLServerPort, + }, + { + name: "fallback with quoted server value", + dataSource: `server="localhost";user id="sa"`, + expectedHost: "localhost", + expectedPort: defaultSQLServerPort, + }, + { + name: "fallback extracts port from server value", + dataSource: "server=localhost:1500;uid=sa", + expectedHost: "localhost:1500", // Fallback returns raw value; msdsn.Parse handles port extraction + expectedPort: defaultSQLServerPort, // fallback doesn't extract from colon format + }, + { + name: "fallback with FQDN", + dataSource: "server=db.example.com;uid=sa;pwd=pass", + expectedHost: "db.example.com", + expectedPort: defaultSQLServerPort, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + host, port, err := parseDataSource(tt.dataSource) + require.NoError(t, err) + assert.Equal(t, tt.expectedHost, host) + assert.Equal(t, tt.expectedPort, port) + }) + } +} + +// BenchmarkParseDataSource benchmarks the parseDataSource function +func BenchmarkParseDataSource(b *testing.B) { + dsn := "server=localhost;user id=sa;password=password" + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _, _ = parseDataSource(dsn) + } +} + +// BenchmarkExtractHostFromDataSource benchmarks the extractHostFromDataSource function +func BenchmarkExtractHostFromDataSource(b *testing.B) { + dsn := "server=localhost;user id=sa;password=password" + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = extractHostFromDataSource(dsn) + } +} + +// BenchmarkComputeServiceInstanceID benchmarks the computeServiceInstanceID function +func BenchmarkComputeServiceInstanceID(b *testing.B) { + cfg := &Config{ + DataSource: "server=localhost;user id=sa;password=password", + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = computeServiceInstanceID(cfg) + } +}