diff --git a/README.md b/README.md index c63cf39..0e4c63c 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,21 @@ Exports metrics at `9127/metrics` make build ./pgbouncer_exporter +## Exporter configuration + +### Command line flags To see all available configuration flags: ./pgbouncer_exporter -h +### Export multiple PGBouncer instances + +If you want to export metrics for multiple PGBouncer instances without running multiple exporters you can use the config.file option + + ./pgbouncer_exporter --config.file config.yaml + +For more information about the possibilities and requirements see [the example config.yaml file within this repo](config.yaml) + ## PGBouncer configuration The pgbouncer\_exporter requires a configuration change to pgbouncer to ignore a PostgreSQL driver connection parameter. In the `pgbouncer.ini` please include this option: diff --git a/collector.go b/collector.go index fb527dd..344c190 100644 --- a/collector.go +++ b/collector.go @@ -133,9 +133,16 @@ var ( ) ) -func NewExporter(connectionString string, namespace string, logger *slog.Logger) *Exporter { +func NewExporter(connectionString string, namespace string, logger *slog.Logger, mustConnect bool) *Exporter { - db, err := getDB(connectionString) + var db *sql.DB + var err error + + if mustConnect { + db, err = getDBWithTest(connectionString) + } else { + db, err = getDB(connectionString) + } if err != nil { logger.Error("error setting up DB connection", "err", err.Error()) @@ -337,15 +344,22 @@ func getDB(conn string) (*sql.DB, error) { if err != nil { return nil, err } + + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + return db, nil +} +func getDBWithTest(conn string) (*sql.DB, error) { + db, err := getDB(conn) + if err != nil { + return nil, err + } rows, err := db.Query("SHOW STATS") if err != nil { return nil, fmt.Errorf("error pinging pgbouncer: %w", err) } defer rows.Close() - - db.SetMaxOpenConns(1) - db.SetMaxIdleConns(1) - return db, nil } diff --git a/config.go b/config.go new file mode 100644 index 0000000..c16fc38 --- /dev/null +++ b/config.go @@ -0,0 +1,133 @@ +// Copyright 2020 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "errors" + "fmt" + "gopkg.in/yaml.v3" + "maps" + "os" + "slices" + "strings" +) + +var ( + ErrNoPgbouncersConfigured = errors.New("no pgbouncer instances configured") + ErrEmptyPgbouncersDSN = errors.New("atleast one pgbouncer instance has an empty dsn configured") +) + +func NewDefaultConfig() *Config { + return &Config{ + MustConnectOnStartup: true, + ExtraLabels: map[string]string{}, + MetricsPath: "/metrics", + PgBouncers: []PgBouncerConfig{}, + } +} + +func NewConfigFromFile(path string) (*Config, error) { + var err error + var data []byte + if path == "" { + return nil, nil + } + config := NewDefaultConfig() + + data, err = os.ReadFile(path) + if err != nil { + return nil, err + } + + err = yaml.Unmarshal(data, config) + if err != nil { + return nil, err + } + + if len(config.PgBouncers) == 0 { + return nil, ErrNoPgbouncersConfigured + } + + for _, instance := range config.PgBouncers { + if strings.TrimSpace(instance.DSN) == "" { + return nil, ErrEmptyPgbouncersDSN + } + } + + return config, nil + +} + +type Config struct { + MustConnectOnStartup bool `yaml:"must_connect_on_startup"` + ExtraLabels map[string]string `yaml:"extra_labels"` + PgBouncers []PgBouncerConfig `yaml:"pgbouncers"` + MetricsPath string `yaml:"metrics_path"` +} +type PgBouncerConfig struct { + DSN string `yaml:"dsn"` + PidFile string `yaml:"pid-file"` + ExtraLabels map[string]string `yaml:"extra_labels"` +} + +func (p *Config) AddPgbouncerConfig(dsn string, pidFilePath string, extraLabels map[string]string) { + p.PgBouncers = append( + p.PgBouncers, + PgBouncerConfig{ + DSN: dsn, + PidFile: pidFilePath, + ExtraLabels: extraLabels, + }, + ) +} + +func (p *Config) MergedExtraLabels(extraLabels map[string]string) map[string]string { + mergedLabels := make(map[string]string) + maps.Copy(mergedLabels, p.ExtraLabels) + maps.Copy(mergedLabels, extraLabels) + + return mergedLabels +} + +func (p Config) ValidateLabels() error { + + var labels = make(map[string]int) + var keys = make(map[string]int) + for _, cfg := range p.PgBouncers { + + var slabels []string + + for k, v := range p.MergedExtraLabels(cfg.ExtraLabels) { + slabels = append(slabels, fmt.Sprintf("%s=%s", k, v)) + keys[k]++ + } + slices.Sort(slabels) + hash := strings.Join(slabels, ",") + if _, ok := labels[hash]; ok { + return fmt.Errorf("Every pgbouncer instance must have unique label values,"+ + " found the following label=value combination multiple times: '%s'", hash) + } + labels[hash] = 1 + } + + for k, amount := range keys { + if amount != len(p.PgBouncers) { + return fmt.Errorf("Every pgbouncer instance must define the same extra labels,"+ + " the label '%s' is only found on %d of the %d instances", k, amount, len(p.PgBouncers)) + } + } + + return nil + +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..6585374 --- /dev/null +++ b/config.yaml @@ -0,0 +1,33 @@ +## must_connect_on_startup: true|false; Default: true +## If true the exporter will fail to start if any connection fails to connect within the startup fase. +## If false the exporter will start even if some connections fail. +must_connect_on_startup: false + +## extra_labels: map of label:value; Default: empty +## These common extra labels will be set on all metrics for all connections. The value can be overridden per connection +## Note: Every connection MUST have the same set of labels but a unique set of values. +extra_labels: + environment: test + +## metrics_path: /path/for/metrics; Default: /metrics +metrics_path: /metrics + +## All the PGBouncers to scrape, +## when multiple connections are used, extra_labels is required to give every connection a unique set of label values +pgbouncers: + - dsn: postgres://postgres:@localhost:6543/pgbouncer?sslmode=disable # Connection string for the pgbouncer instance (Required) + pid-file: /path/to/pidfile # Add path to pgbouncer pid file to enable the process exporter metrics, Default: empty + extra_labels: # Extra labels to identify the metrics for each instance. As mentioned + pgbouncer_instance: set1-0 # Example: a unique identifier for each pgbouncer instance. + environment: prod # Example: a shared label for multiple pgbouncer instances + - dsn: postgres://postgres:@localhost:6544/pgbouncer?sslmode=disable + pid-file: + extra_labels: + pgbouncer_instance: set1-1 + environment: prod + - dsn: postgres://postgres:@localhost:6545/pgbouncer?sslmode=disable + pid-file: + extra_labels: + pgbouncer_instance: set2-0 + ## the metrics of this instance will have the additional labels: {environment: "test", pgbouncer_instance: "set2-0"} + ## as `environment: "test"` is inherited from common extra_labels diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..62e70f8 --- /dev/null +++ b/config_test.go @@ -0,0 +1,156 @@ +// Copyright 2024 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific langu +package main + +import ( + "errors" + "github.com/google/go-cmp/cmp" + "io/fs" + "maps" + "strings" + "testing" +) + +func TestDefaultConfig(t *testing.T) { + + config := NewDefaultConfig() + + MustConnectOnStartupWant := true + if config.MustConnectOnStartup != MustConnectOnStartupWant { + t.Errorf("MustConnectOnStartup does not match. Want: %v, Got: %v", MustConnectOnStartupWant, config.MustConnectOnStartup) + } + + MetricsPathWant := "/metrics" + if config.MetricsPath != MetricsPathWant { + t.Errorf("MustConnectOnStartup does not match. Want: %v, Got: %v", MetricsPathWant, config.MustConnectOnStartup) + } + +} + +func TestUnHappyFileConfig(t *testing.T) { + + var config *Config + var err error + + config, err = NewConfigFromFile("") + if config != nil || err != nil { + t.Errorf("NewConfigFromFile should return nil for config and error if path is empty. Got: %v", err) + } + + _, err = NewConfigFromFile("./testdata/i-do-not-exist.yaml") + if errors.Is(err, fs.ErrNotExist) == false { + t.Errorf("NewConfigFromFile should return fs.ErrNotExist error. Got: %v", err) + } + + _, err = NewConfigFromFile("./testdata/parse_error.yaml") + if err != nil && strings.Contains(err.Error(), "yaml: line") == false { + t.Errorf("NewConfigFromFile should return yaml parse error. Got: %v", err) + } + + _, err = NewConfigFromFile("./testdata/empty.yaml") + if errors.Is(err, ErrNoPgbouncersConfigured) == false { + t.Errorf("NewConfigFromFile should return ErrNoPgbouncersConfigured error. Got: %v", err) + } + + _, err = NewConfigFromFile("./testdata/no-dsn.yaml") + if errors.Is(err, ErrEmptyPgbouncersDSN) == false { + t.Errorf("NewConfigFromFile should return ErrEmptyPgbouncersDSN error. Got: %v", err) + } + +} + +func TestFileConfig(t *testing.T) { + + var config *Config + var err error + + config, err = NewConfigFromFile("./testdata/config.yaml") + if err != nil { + t.Errorf("NewConfigFromFile() should not throw an error: %v", err) + } + + MustConnectOnStartupWant := false + if config.MustConnectOnStartup != MustConnectOnStartupWant { + t.Errorf("MustConnectOnStartup does not match. Want: %v, Got: %v", MustConnectOnStartupWant, config.MustConnectOnStartup) + } + + MetricsPathWant := "/prom" + if config.MetricsPath != MetricsPathWant { + t.Errorf("MustConnectOnStartup does not match. Want: %v, Got: %v", MetricsPathWant, config.MustConnectOnStartup) + } + + CommonExtraLabelsWant := map[string]string{"environment": "sandbox"} + if maps.Equal(config.ExtraLabels, CommonExtraLabelsWant) == false { + t.Errorf("ExtraLabels does not match. Want: %v, Got: %v", CommonExtraLabelsWant, config.ExtraLabels) + } + + pgWants := []PgBouncerConfig{ + { + DSN: "postgres://postgres:@localhost:6543/pgbouncer?sslmode=disable", + PidFile: "/var/run/pgbouncer1.pid", + ExtraLabels: map[string]string{"pgbouncer_instance": "set1-0", "environment": "prod"}, + }, + { + DSN: "postgres://postgres:@localhost:6544/pgbouncer?sslmode=disable", + PidFile: "/var/run/pgbouncer2.pid", + ExtraLabels: map[string]string{"pgbouncer_instance": "set1-1", "environment": "prod"}, + }, + } + + for i := range pgWants { + if cmp.Equal(config.PgBouncers[i], pgWants[i]) == false { + t.Errorf("PGBouncer config %d does not match. Want: %v, Got: %v", i, pgWants[i], config.PgBouncers[i]) + } + } + + err = config.ValidateLabels() + if err != nil { + t.Errorf("ValidateLabels() throws an unexpected error: %v", err) + } + +} + +func TestNotUniqueLabels(t *testing.T) { + + config := NewDefaultConfig() + + config.AddPgbouncerConfig("", "", map[string]string{"pgbouncer_instance": "set1-0", "environment": "prod"}) + config.AddPgbouncerConfig("", "", map[string]string{"pgbouncer_instance": "set1-0", "environment": "prod"}) + + err := config.ValidateLabels() + if err == nil { + t.Errorf("ValidateLabels() did not throw an error ") + } + errorWant := "Every pgbouncer instance must have unique label values, found the following label=value combination multiple times: 'environment=prod,pgbouncer_instance=set1-0'" + if err.Error() != errorWant { + t.Errorf("ValidateLabels() did not throw the expected error.\n- Want: %s\n- Got: %s", errorWant, err.Error()) + } + +} + +func TestMissingLabels(t *testing.T) { + + config := NewDefaultConfig() + + config.AddPgbouncerConfig("", "", map[string]string{"pgbouncer_instance": "set1-0", "environment": "prod"}) + config.AddPgbouncerConfig("", "", map[string]string{"pgbouncer_instance": "set1-0"}) + + err := config.ValidateLabels() + if err == nil { + t.Errorf("ValidateLabels() did not throw an error ") + } + errorWant := "Every pgbouncer instance must define the same extra labels, the label 'environment' is only found on 1 of the 2 instances" + if err.Error() != errorWant { + t.Errorf("ValidateLabels() did not throw the expected error.\n- Want: %s\n- Got: %s", errorWant, err.Error()) + } + +} diff --git a/go.mod b/go.mod index 7434de1..e0ef210 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,14 @@ go 1.22 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/alecthomas/kingpin/v2 v2.4.0 + github.com/google/go-cmp v0.6.0 github.com/lib/pq v1.10.9 github.com/prometheus/client_golang v1.20.4 github.com/prometheus/client_model v0.6.1 github.com/prometheus/common v0.60.0 github.com/prometheus/exporter-toolkit v0.13.0 github.com/smartystreets/goconvey v1.8.1 + gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/pgbouncer_exporter.go b/pgbouncer_exporter.go index a7e112b..3d43f94 100644 --- a/pgbouncer_exporter.go +++ b/pgbouncer_exporter.go @@ -31,6 +31,13 @@ import ( const namespace = "pgbouncer" +const ( + _ = iota + ExitCodeWebServerError + ExitConfigFileReadError + ExitConfigFileContentError +) + func main() { const pidFileHelpText = `Path to PgBouncer pid file. @@ -41,13 +48,23 @@ func main() { https://prometheus.io/docs/instrumenting/writing_clientlibs/#process-metrics.` + const cfgFileHelpText = `Path to config file for multiple pgbouncer instances . + + If provided, the standard pgbouncer parameters, 'pgBouncer.connectionString' + and 'pgBouncer.pid-file', will be ignored and read from the config file` + + config := NewDefaultConfig() + promslogConfig := &promslog.Config{} flag.AddFlags(kingpin.CommandLine, promslogConfig) var ( connectionStringPointer = kingpin.Flag("pgBouncer.connectionString", "Connection string for accessing pgBouncer.").Default("postgres://postgres:@localhost:6543/pgbouncer?sslmode=disable").Envar("PGBOUNCER_EXPORTER_CONNECTION_STRING").String() - metricsPath = kingpin.Flag("web.telemetry-path", "Path under which to expose metrics.").Default("/metrics").String() + metricsPath = kingpin.Flag("web.telemetry-path", "Path under which to expose metrics.").Default(config.MetricsPath).String() pidFilePath = kingpin.Flag("pgBouncer.pid-file", pidFileHelpText).Default("").String() + + configFilePath = kingpin.Flag("config.file", cfgFileHelpText).Default("").String() + err error ) toolkitFlags := kingpinflag.AddFlags(kingpin.CommandLine, ":9127") @@ -58,26 +75,55 @@ func main() { logger := promslog.New(promslogConfig) - connectionString := *connectionStringPointer - exporter := NewExporter(connectionString, namespace, logger) - prometheus.MustRegister(exporter) + // If config file is used, read config from file + // Else use the legacy command-line parameters + if configFilePath != nil && *configFilePath != "" { + config, err = NewConfigFromFile(*configFilePath) + if err != nil { + logger.Error("Error reading config file", "file", *configFilePath, "err", err) + os.Exit(ExitConfigFileReadError) + } + } else { + config.AddPgbouncerConfig(*connectionStringPointer, *pidFilePath, nil) + config.MetricsPath = *metricsPath + } + + // When running multiple connection every connection must have the same labels but a unique value combination + if err = config.ValidateLabels(); err != nil { + logger.Error("Error while validating labels: ", "file", *configFilePath, "err", err) + os.Exit(ExitConfigFileContentError) + } + + // Add an exporter for each connection with the extra labels merged + reg := prometheus.DefaultRegisterer + for _, pgbouncer := range config.PgBouncers { + + // Merge the comment extra_labels with the extra_labels per connection + extraLabels := config.MergedExtraLabels(pgbouncer.ExtraLabels) + + // Add base exporter + exporter := NewExporter(pgbouncer.DSN, namespace, logger, config.MustConnectOnStartup) + prometheus.WrapRegistererWith(extraLabels, reg).MustRegister(exporter) + + // Add process exporter + if pgbouncer.PidFile != "" { + procExporter := collectors.NewProcessCollector( + collectors.ProcessCollectorOpts{ + PidFn: prometheus.NewPidFileFn(pgbouncer.PidFile), + Namespace: namespace, + }, + ) + prometheus.WrapRegistererWith(extraLabels, reg).MustRegister(procExporter) + } + } + prometheus.MustRegister(versioncollector.NewCollector("pgbouncer_exporter")) logger.Info("Starting pgbouncer_exporter", "version", version.Info()) logger.Info("Build context", "build_context", version.BuildContext()) - if *pidFilePath != "" { - procExporter := collectors.NewProcessCollector( - collectors.ProcessCollectorOpts{ - PidFn: prometheus.NewPidFileFn(*pidFilePath), - Namespace: namespace, - }, - ) - prometheus.MustRegister(procExporter) - } - - http.Handle(*metricsPath, promhttp.Handler()) - if *metricsPath != "/" && *metricsPath != "" { + http.Handle(config.MetricsPath, promhttp.Handler()) + if config.MetricsPath != "/" && config.MetricsPath != "" { landingConfig := web.LandingConfig{ Name: "PgBouncer Exporter", Description: "Prometheus Exporter for PgBouncer servers", @@ -100,6 +146,6 @@ func main() { srv := &http.Server{} if err := web.ListenAndServe(srv, toolkitFlags, logger); err != nil { logger.Error("Error starting server", "err", err) - os.Exit(1) + os.Exit(ExitCodeWebServerError) } } diff --git a/testdata/config.yaml b/testdata/config.yaml new file mode 100644 index 0000000..34ebdd8 --- /dev/null +++ b/testdata/config.yaml @@ -0,0 +1,17 @@ +must_connect_on_startup: false +extra_labels: + environment: sandbox + +metrics_path: /prom + +pgbouncers: + - dsn: postgres://postgres:@localhost:6543/pgbouncer?sslmode=disable + pid-file: /var/run/pgbouncer1.pid + extra_labels: + pgbouncer_instance: set1-0 + environment: prod + - dsn: postgres://postgres:@localhost:6544/pgbouncer?sslmode=disable + pid-file: /var/run/pgbouncer2.pid + extra_labels: + pgbouncer_instance: set1-1 + environment: prod diff --git a/testdata/empty.yaml b/testdata/empty.yaml new file mode 100644 index 0000000..424cb0f --- /dev/null +++ b/testdata/empty.yaml @@ -0,0 +1,7 @@ +must_connect_on_startup: false +extra_labels: + environment: sandbox + +metrics_path: /prom + +pgbouncers: diff --git a/testdata/no-dsn.yaml b/testdata/no-dsn.yaml new file mode 100644 index 0000000..1c2bf42 --- /dev/null +++ b/testdata/no-dsn.yaml @@ -0,0 +1,9 @@ +must_connect_on_startup: false +extra_labels: + environment: sandbox + +metrics_path: /prom + +pgbouncers: + - dsn: + - dsn: postgres://postgres:@localhost:6544/pgbouncer?sslmode=disable diff --git a/testdata/parse_error.yaml b/testdata/parse_error.yaml new file mode 100644 index 0000000..5558a45 --- /dev/null +++ b/testdata/parse_error.yaml @@ -0,0 +1,6 @@ +# yamllint disable-file +# This file is invalid on purpose and should throw errors +must_connect_on_startup: false +extra_labels: + environment: sandbox + - trigger: parse_error #syntax error: mapping values are not allowed here (syntax)