Skip to content

Commit daf69cc

Browse files
authored
feat(query-performance-monitoring): Implement query performance monitoring (#189)
feat(query-performance-monitoring): Implement query performance monitoring
1 parent d7cc25a commit daf69cc

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+3337
-140
lines changed

Makefile

+13-3
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,19 @@ test:
3232

3333
integration-test:
3434
@echo "=== $(INTEGRATION) === [ test ]: running integration tests..."
35-
@docker compose -f tests/docker-compose.yml pull
36-
@go test -v -tags=integration -count 1 ./tests/. || (ret=$$?; docker compose -f tests/docker-compose.yml down && exit $$ret)
37-
@docker compose -f tests/docker-compose.yml down
35+
@docker compose -f tests/docker-compose.yml up -d
36+
# Sleep added to allow postgres with test data and extensions to start up
37+
@sleep 10
38+
@go test -v -tags=integration -count 1 ./tests/postgresql_test.go -timeout 300s || (ret=$$?; docker compose -f tests/docker-compose.yml down -v && exit $$ret)
39+
@docker compose -f tests/docker-compose.yml down -v
40+
@echo "=== $(INTEGRATION) === [ test ]: running integration tests for query performance monitoring..."
41+
@echo "Starting containers for performance tests..."
42+
@docker compose -f tests/docker-compose-performance.yml up -d
43+
# Sleep added to allow postgres with test data and extensions to start up
44+
@sleep 30
45+
@go test -v -tags=query_performance ./tests/postgresqlperf_test.go -timeout 600s || (ret=$$?; docker compose -f tests/docker-compose-performance.yml down -v && exit $$ret)
46+
@echo "Stopping performance test containers..."
47+
@docker compose -f tests/docker-compose-performance.yml down -v
3848

3949
install: compile
4050
@echo "=== $(INTEGRATION) === [ install ]: installing bin/$(BINARY_NAME)..."

go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ require (
1111
github.com/xeipuuv/gojsonschema v1.2.0
1212
gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0
1313
gopkg.in/yaml.v3 v3.0.1
14+
github.com/go-viper/mapstructure/v2 v2.2.1
1415
)
1516

1617
require (

go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
88
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
99
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
1010
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
11+
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
12+
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
1113
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
1214
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
1315
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=

postgresql-config.yml.sample

+9
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ integrations:
5050
# True if SSL is to be used. Defaults to false.
5151
ENABLE_SSL: "false"
5252

53+
# Enable query performance monitoring - Defaults to false
54+
# ENABLE_QUERY_MONITORING : "false"
55+
56+
# Threshold in milliseconds for query response time to fetch individual query performance metrics - Defaults to 500
57+
# QUERY_MONITORING_RESPONSE_TIME_THRESHOLD : "500"
58+
59+
# The number of records for each query performance metrics - Defaults to 20
60+
# QUERY_MONITORING_COUNT_THRESHOLD : "20"
61+
5362
# True if the SSL certificate should be trusted without validating.
5463
# Setting this to true may open up the monitoring service to MITM attacks.
5564
# Defaults to false.

src/args/argument_list.go

+23-23
Original file line numberDiff line numberDiff line change
@@ -3,46 +3,46 @@ package args
33

44
import (
55
"errors"
6-
76
sdkArgs "github.com/newrelic/infra-integrations-sdk/v3/args"
87
"github.com/newrelic/infra-integrations-sdk/v3/log"
98
)
109

1110
// ArgumentList struct that holds all PostgreSQL arguments
1211
type ArgumentList struct {
1312
sdkArgs.DefaultArgumentList
14-
Username string `default:"" help:"The username for the PostgreSQL database"`
15-
Password string `default:"" help:"The password for the specified username"`
16-
Hostname string `default:"localhost" help:"The PostgreSQL hostname to connect to"`
17-
Database string `default:"postgres" help:"The PostgreSQL database name to connect to"`
18-
Port string `default:"5432" help:"The port to connect to the PostgreSQL database"`
19-
CollectionList string `default:"{}" help:"A JSON object which defines the databases, schemas, tables, and indexes to collect. Can also be a JSON array that list databases to be collected. Can also be the string literal 'ALL' to collect everything. Collects nothing by default."`
20-
CollectionIgnoreDatabaseList string `default:"[]" help:"A JSON array that list databases that will be excluded from collection. Nothing is excluded by default."`
21-
CollectionIgnoreTableList string `default:"[]" help:"A JSON array that list tables that will be excluded from collection. Nothing is excluded by default."`
22-
SSLRootCertLocation string `default:"" help:"Absolute path to PEM encoded root certificate file"`
23-
SSLCertLocation string `default:"" help:"Absolute path to PEM encoded client cert file"`
24-
SSLKeyLocation string `default:"" help:"Absolute path to PEM encoded client key file"`
25-
Timeout string `default:"10" help:"Maximum wait for connection, in seconds. Set 0 for no timeout"`
26-
CustomMetricsQuery string `default:"" help:"A SQL query to collect custom metrics. Must have the columns metric_name, metric_type, and metric_value. Additional columns are added as attributes"`
27-
CustomMetricsConfig string `default:"" help:"YAML configuration with one or more custom SQL queries to collect"`
28-
EnableSSL bool `default:"false" help:"If true will use SSL encryption, false will not use encryption"`
29-
TrustServerCertificate bool `default:"false" help:"If true server certificate is not verified for SSL. If false certificate will be verified against supplied certificate"`
30-
Pgbouncer bool `default:"false" help:"Collects metrics from PgBouncer instance. Assumes connection is through PgBouncer."`
31-
CollectDbLockMetrics bool `default:"false" help:"If true, enables collection of lock metrics for the specified database. (Note: requires that the 'tablefunc' extension is installed)"` //nolint: stylecheck
32-
CollectBloatMetrics bool `default:"true" help:"Enable collecting bloat metrics which can be performance intensive"`
33-
ShowVersion bool `default:"false" help:"Print build information and exit"`
13+
Username string `default:"" help:"The username for the PostgreSQL database"`
14+
Password string `default:"" help:"The password for the specified username"`
15+
Hostname string `default:"localhost" help:"The PostgreSQL hostname to connect to"`
16+
Database string `default:"postgres" help:"The PostgreSQL database name to connect to"`
17+
Port string `default:"5432" help:"The port to connect to the PostgreSQL database"`
18+
CollectionList string `default:"{}" help:"A JSON object which defines the databases, schemas, tables, and indexes to collect. Can also be a JSON array that list databases to be collected. Can also be the string literal 'ALL' to collect everything. Collects nothing by default."`
19+
CollectionIgnoreDatabaseList string `default:"[]" help:"A JSON array that list databases that will be excluded from collection. Nothing is excluded by default."`
20+
CollectionIgnoreTableList string `default:"[]" help:"A JSON array that list tables that will be excluded from collection. Nothing is excluded by default."`
21+
SSLRootCertLocation string `default:"" help:"Absolute path to PEM encoded root certificate file"`
22+
SSLCertLocation string `default:"" help:"Absolute path to PEM encoded client cert file"`
23+
SSLKeyLocation string `default:"" help:"Absolute path to PEM encoded client key file"`
24+
Timeout string `default:"10" help:"Maximum wait for connection, in seconds. Set 0 for no timeout"`
25+
CustomMetricsQuery string `default:"" help:"A SQL query to collect custom metrics. Must have the columns metric_name, metric_type, and metric_value. Additional columns are added as attributes"`
26+
CustomMetricsConfig string `default:"" help:"YAML configuration with one or more custom SQL queries to collect"`
27+
EnableSSL bool `default:"false" help:"If true will use SSL encryption, false will not use encryption"`
28+
TrustServerCertificate bool `default:"false" help:"If true server certificate is not verified for SSL. If false certificate will be verified against supplied certificate"`
29+
Pgbouncer bool `default:"false" help:"Collects metrics from PgBouncer instance. Assumes connection is through PgBouncer."`
30+
CollectDbLockMetrics bool `default:"false" help:"If true, enables collection of lock metrics for the specified database. (Note: requires that the 'tablefunc' extension is installed)"` //nolint: stylecheck
31+
CollectBloatMetrics bool `default:"true" help:"Enable collecting bloat metrics which can be performance intensive"`
32+
ShowVersion bool `default:"false" help:"Print build information and exit"`
33+
EnableQueryMonitoring bool `default:"false" help:"Enable collection of detailed query performance metrics."`
34+
QueryMonitoringResponseTimeThreshold int `default:"500" help:"Threshold in milliseconds for query response time. If response time for the individual query exceeds this threshold, the individual query is reported in metrics"`
35+
QueryMonitoringCountThreshold int `default:"20" help:"The number of records for each query performance metrics"`
3436
}
3537

3638
// Validate validates PostgreSQl arguments
3739
func (al ArgumentList) Validate() error {
3840
if al.Username == "" || al.Password == "" {
3941
return errors.New("invalid configuration: must specify a username and password")
4042
}
41-
4243
if err := al.validateSSL(); err != nil {
4344
return err
4445
}
45-
4646
return nil
4747
}
4848

src/connection/pgsql_connection.go

+1-2
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ func (p PGSQLConnection) HaveExtensionInSchema(extensionName, schemaName string)
149149
return true
150150
}
151151

152-
// createConnectionURL creates the connection string. A list of paramters
152+
// createConnectionURL creates the connection string. A list of parameters
153153
// can be found here https://godoc.org/github.com/lib/pq#hdr-Connection_String_Parameters
154154
func createConnectionURL(ci *connectionInfo, database string) string {
155155
connectionURL := &url.URL{
@@ -170,7 +170,6 @@ func createConnectionURL(ci *connectionInfo, database string) string {
170170
}
171171

172172
connectionURL.RawQuery = query.Encode()
173-
174173
return connectionURL.String()
175174
}
176175

src/main.go

+8-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"runtime"
88
"strings"
99

10+
queryperformancemonitoring "github.com/newrelic/nri-postgresql/src/query-performance-monitoring"
11+
1012
"github.com/newrelic/infra-integrations-sdk/v3/integration"
1113
"github.com/newrelic/infra-integrations-sdk/v3/log"
1214
"github.com/newrelic/nri-postgresql/src/args"
@@ -27,6 +29,7 @@ var (
2729
)
2830

2931
func main() {
32+
3033
var args args.ArgumentList
3134
// Create Integration
3235
pgIntegration, err := integration.New(integrationName, integrationVersion, integration.Args(&args))
@@ -62,7 +65,6 @@ func main() {
6265
log.Error("Error creating list of entities to collect: %s", err)
6366
os.Exit(1)
6467
}
65-
6668
instance, err := pgIntegration.Entity(fmt.Sprintf("%s:%s", args.Hostname, args.Port), "pg-instance")
6769
if err != nil {
6870
log.Error("Error creating instance entity: %s", err.Error())
@@ -89,4 +91,9 @@ func main() {
8991
if err = pgIntegration.Publish(); err != nil {
9092
log.Error(err.Error())
9193
}
94+
95+
if args.EnableQueryMonitoring {
96+
queryperformancemonitoring.QueryPerformanceMain(args, pgIntegration, collectionList)
97+
}
98+
9299
}

src/metrics/metrics.go

+2-3
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func PopulateMetrics(
3737
}
3838
defer con.Close()
3939

40-
version, err := collectVersion(con)
40+
version, err := CollectVersion(con)
4141
if err != nil {
4242
log.Error("Metrics collection failed: error collecting version number: %s", err.Error())
4343
return
@@ -223,15 +223,14 @@ type serverVersionRow struct {
223223
Version string `db:"server_version"`
224224
}
225225

226-
func collectVersion(connection *connection.PGSQLConnection) (*semver.Version, error) {
226+
func CollectVersion(connection *connection.PGSQLConnection) (*semver.Version, error) {
227227
var versionRows []*serverVersionRow
228228
if err := connection.Query(&versionRows, versionQuery); err != nil {
229229
return nil, err
230230
}
231231

232232
re := regexp.MustCompile(`[0-9]+\.[0-9]+(\.[0-9])?`)
233233
version := re.FindString(versionRows[0].Version)
234-
235234
// special cases for ubuntu/debian parsing
236235
//version := versionRows[0].Version
237236
//if strings.Contains(version, "Ubuntu") {

src/metrics/version_test.go

+5-5
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func Test_collectVersion(t *testing.T) {
2222
Minor: 3,
2323
}
2424

25-
version, err := collectVersion(testConnection)
25+
version, err := CollectVersion(testConnection)
2626

2727
assert.Nil(t, err)
2828
assert.Equal(t, expected, version)
@@ -42,7 +42,7 @@ func Test_collectVersion_EnterpriseDB(t *testing.T) {
4242
Patch: 7,
4343
}
4444

45-
version, err := collectVersion(testConnection)
45+
version, err := CollectVersion(testConnection)
4646

4747
assert.Nil(t, err)
4848
assert.Equal(t, expected, version)
@@ -61,7 +61,7 @@ func Test_collectVersion_Ubuntu(t *testing.T) {
6161
Minor: 4,
6262
}
6363

64-
version, err := collectVersion(testConnection)
64+
version, err := CollectVersion(testConnection)
6565

6666
assert.Nil(t, err)
6767
assert.Equal(t, expected, version)
@@ -80,7 +80,7 @@ func Test_collectVersion_Debian(t *testing.T) {
8080
Minor: 4,
8181
}
8282

83-
version, err := collectVersion(testConnection)
83+
version, err := CollectVersion(testConnection)
8484

8585
assert.Nil(t, err)
8686
assert.Equal(t, expected, version)
@@ -94,7 +94,7 @@ func Test_collectVersion_Err(t *testing.T) {
9494

9595
mock.ExpectQuery(versionQuery).WillReturnRows(versionRows)
9696

97-
_, err := collectVersion(testConnection)
97+
_, err := CollectVersion(testConnection)
9898

9999
assert.NotNil(t, err)
100100
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package commonparameters
2+
3+
import (
4+
"github.com/newrelic/infra-integrations-sdk/v3/log"
5+
"github.com/newrelic/nri-postgresql/src/args"
6+
)
7+
8+
// The maximum number records that can be fetched in a single metrics
9+
const MaxQueryCountThreshold = 30
10+
11+
// DefaultQueryMonitoringCountThreshold is the default threshold for the number of queries to monitor.
12+
const DefaultQueryMonitoringCountThreshold = 20
13+
14+
// DefaultQueryResponseTimeThreshold is the default threshold for the response time of a query.
15+
const DefaultQueryResponseTimeThreshold = 500
16+
17+
type CommonParameters struct {
18+
Version uint64
19+
Databases string
20+
QueryMonitoringCountThreshold int
21+
QueryMonitoringResponseTimeThreshold int
22+
Host string
23+
Port string
24+
}
25+
26+
func SetCommonParameters(args args.ArgumentList, version uint64, databases string) *CommonParameters {
27+
return &CommonParameters{
28+
Version: version,
29+
Databases: databases, // comma separated database names
30+
QueryMonitoringCountThreshold: validateAndGetQueryMonitoringCountThreshold(args),
31+
QueryMonitoringResponseTimeThreshold: validateAndGetQueryMonitoringResponseTimeThreshold(args),
32+
Host: args.Hostname,
33+
Port: args.Port,
34+
}
35+
}
36+
37+
func validateAndGetQueryMonitoringResponseTimeThreshold(args args.ArgumentList) int {
38+
if args.QueryMonitoringResponseTimeThreshold < 0 {
39+
log.Warn("QueryResponseTimeThreshold should be greater than or equal to 0 but the input is %d, setting value to default which is %d", args.QueryMonitoringResponseTimeThreshold, DefaultQueryResponseTimeThreshold)
40+
return DefaultQueryResponseTimeThreshold
41+
}
42+
return args.QueryMonitoringResponseTimeThreshold
43+
}
44+
45+
func validateAndGetQueryMonitoringCountThreshold(args args.ArgumentList) int {
46+
if args.QueryMonitoringCountThreshold < 0 {
47+
log.Warn("QueryCountThreshold should be greater than 0 but the input is %d, setting value to default which is %d", args.QueryMonitoringCountThreshold, DefaultQueryMonitoringCountThreshold)
48+
return DefaultQueryMonitoringCountThreshold
49+
}
50+
if args.QueryMonitoringCountThreshold > MaxQueryCountThreshold {
51+
log.Warn("QueryCountThreshold should be less than or equal to max limit but the input is %d, setting value to max limit which is %d", args.QueryMonitoringCountThreshold, MaxQueryCountThreshold)
52+
return MaxQueryCountThreshold
53+
}
54+
return args.QueryMonitoringCountThreshold
55+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package commonutils
2+
3+
import (
4+
"crypto/rand"
5+
"fmt"
6+
"math/big"
7+
"regexp"
8+
"strings"
9+
"time"
10+
11+
"github.com/newrelic/nri-postgresql/src/collection"
12+
)
13+
14+
// re is a regular expression that matches single-quoted strings, numbers, or double-quoted strings
15+
var re = regexp.MustCompile(`'[^']*'|\d+|".*?"`)
16+
17+
func GetDatabaseListInString(dbMap collection.DatabaseList) string {
18+
if len(dbMap) == 0 {
19+
return ""
20+
}
21+
var quotedNames = make([]string, 0)
22+
for dbName := range dbMap {
23+
quotedNames = append(quotedNames, fmt.Sprintf("'%s'", dbName))
24+
}
25+
return strings.Join(quotedNames, ",")
26+
}
27+
28+
func AnonymizeQueryText(query string) string {
29+
anonymizedQuery := re.ReplaceAllString(query, "?")
30+
return anonymizedQuery
31+
}
32+
33+
// This function is used to generate a unique plan ID for a query
34+
func GeneratePlanID() (string, error) {
35+
randomInt, err := rand.Int(rand.Reader, big.NewInt(RandomIntRange))
36+
if err != nil {
37+
return "", ErrUnExpectedError
38+
}
39+
currentTime := time.Now().Format(TimeFormat)
40+
result := fmt.Sprintf("%d-%s", randomInt.Int64(), currentTime)
41+
return result, nil
42+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package commonutils
2+
3+
import (
4+
"sort"
5+
"testing"
6+
7+
"github.com/newrelic/nri-postgresql/src/collection"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestGetDatabaseListInString(t *testing.T) {
12+
dbListKeys := []string{"db1"}
13+
sort.Strings(dbListKeys) // Sort the keys to ensure consistent order
14+
dbList := collection.DatabaseList{}
15+
for _, key := range dbListKeys {
16+
dbList[key] = collection.SchemaList{}
17+
}
18+
expected := "'db1'"
19+
result := GetDatabaseListInString(dbList)
20+
assert.Equal(t, expected, result)
21+
22+
// Test with empty database list
23+
dbList = collection.DatabaseList{}
24+
expected = ""
25+
result = GetDatabaseListInString(dbList)
26+
assert.Equal(t, expected, result)
27+
}
28+
29+
func TestAnonymizeQueryText(t *testing.T) {
30+
query := "SELECT * FROM users WHERE id = 1 AND name = 'John'"
31+
expected := "SELECT * FROM users WHERE id = ? AND name = ?"
32+
result := AnonymizeQueryText(query)
33+
assert.Equal(t, expected, result)
34+
query = "SELECT * FROM employees WHERE id = 10 OR name <> 'John Doe' OR name != 'John Doe' OR age < 30 OR age <= 30 OR salary > 50000OR salary >= 50000 OR department LIKE 'Sales%' OR department ILIKE 'sales%'OR join_date BETWEEN '2023-01-01' AND '2023-12-31' OR department IN ('HR', 'Engineering', 'Marketing') OR department IS NOT NULL OR department IS NULL;"
35+
expected = "SELECT * FROM employees WHERE id = ? OR name <> ? OR name != ? OR age < ? OR age <= ? OR salary > ?OR salary >= ? OR department LIKE ? OR department ILIKE ?OR join_date BETWEEN ? AND ? OR department IN (?, ?, ?) OR department IS NOT NULL OR department IS NULL;"
36+
result = AnonymizeQueryText(query)
37+
assert.Equal(t, expected, result)
38+
}

0 commit comments

Comments
 (0)