Skip to content

Commit af1f42f

Browse files
committed
Feat(config): add option to export multiple pgbouncer instances with one exporter instance.
1 parent 6ecb3e5 commit af1f42f

11 files changed

+457
-23
lines changed

Diff for: README.md

+11
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,21 @@ Exports metrics at `9127/metrics`
99
make build
1010
./pgbouncer_exporter <flags>
1111

12+
## Exporter configuration
13+
14+
### Command line flags
1215
To see all available configuration flags:
1316

1417
./pgbouncer_exporter -h
1518

19+
### Export multiple PGBouncer instances
20+
21+
If you want to export metrics for multiple PGBouncer instances without running multiple exporters you can use the config.file option
22+
23+
./pgbouncer_exporter --config.file config.yaml
24+
25+
For more information about the possibilities and requirements see [the example config.yaml file within this repo](config.yaml)
26+
1627
## PGBouncer configuration
1728

1829
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 for: collector.go

+20-6
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,16 @@ var (
133133
)
134134
)
135135

136-
func NewExporter(connectionString string, namespace string, logger *slog.Logger) *Exporter {
136+
func NewExporter(connectionString string, namespace string, logger *slog.Logger, mustConnect bool) *Exporter {
137137

138-
db, err := getDB(connectionString)
138+
var db *sql.DB
139+
var err error
140+
141+
if mustConnect {
142+
db, err = getDBWithTest(connectionString)
143+
} else {
144+
db, err = getDB(connectionString)
145+
}
139146

140147
if err != nil {
141148
logger.Error("error setting up DB connection", "err", err.Error())
@@ -337,15 +344,22 @@ func getDB(conn string) (*sql.DB, error) {
337344
if err != nil {
338345
return nil, err
339346
}
347+
348+
db.SetMaxOpenConns(1)
349+
db.SetMaxIdleConns(1)
350+
351+
return db, nil
352+
}
353+
func getDBWithTest(conn string) (*sql.DB, error) {
354+
db, err := getDB(conn)
355+
if err != nil {
356+
return nil, err
357+
}
340358
rows, err := db.Query("SHOW STATS")
341359
if err != nil {
342360
return nil, fmt.Errorf("error pinging pgbouncer: %w", err)
343361
}
344362
defer rows.Close()
345-
346-
db.SetMaxOpenConns(1)
347-
db.SetMaxIdleConns(1)
348-
349363
return db, nil
350364
}
351365

Diff for: config.go

+133
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright 2020 The Prometheus Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package main
15+
16+
import (
17+
"errors"
18+
"fmt"
19+
"gopkg.in/yaml.v3"
20+
"maps"
21+
"os"
22+
"slices"
23+
"strings"
24+
)
25+
26+
var (
27+
ErrNoPgbouncersConfigured = errors.New("no pgbouncer instances configured")
28+
ErrEmptyPgbouncersDSN = errors.New("atleast one pgbouncer instance has an empty dsn configured")
29+
)
30+
31+
func NewDefaultConfig() *Config {
32+
return &Config{
33+
MustConnectOnStartup: true,
34+
ExtraLabels: map[string]string{},
35+
MetricsPath: "/metrics",
36+
PgBouncers: []PgBouncerConfig{},
37+
}
38+
}
39+
40+
func NewConfigFromFile(path string) (*Config, error) {
41+
var err error
42+
var data []byte
43+
if path == "" {
44+
return nil, nil
45+
}
46+
config := NewDefaultConfig()
47+
48+
data, err = os.ReadFile(path)
49+
if err != nil {
50+
return nil, err
51+
}
52+
53+
err = yaml.Unmarshal(data, config)
54+
if err != nil {
55+
return nil, err
56+
}
57+
58+
if len(config.PgBouncers) == 0 {
59+
return nil, ErrNoPgbouncersConfigured
60+
}
61+
62+
for _, instance := range config.PgBouncers {
63+
if strings.TrimSpace(instance.DSN) == "" {
64+
return nil, ErrEmptyPgbouncersDSN
65+
}
66+
}
67+
68+
return config, nil
69+
70+
}
71+
72+
type Config struct {
73+
MustConnectOnStartup bool `yaml:"must_connect_on_startup"`
74+
ExtraLabels map[string]string `yaml:"extra_labels"`
75+
PgBouncers []PgBouncerConfig `yaml:"pgbouncers"`
76+
MetricsPath string `yaml:"metrics_path"`
77+
}
78+
type PgBouncerConfig struct {
79+
DSN string `yaml:"dsn"`
80+
PidFile string `yaml:"pid-file"`
81+
ExtraLabels map[string]string `yaml:"extra_labels"`
82+
}
83+
84+
func (p *Config) AddPgbouncerConfig(dsn string, pidFilePath string, extraLabels map[string]string) {
85+
p.PgBouncers = append(
86+
p.PgBouncers,
87+
PgBouncerConfig{
88+
DSN: dsn,
89+
PidFile: pidFilePath,
90+
ExtraLabels: extraLabels,
91+
},
92+
)
93+
}
94+
95+
func (p *Config) MergedExtraLabels(extraLabels map[string]string) map[string]string {
96+
mergedLabels := make(map[string]string)
97+
maps.Copy(mergedLabels, p.ExtraLabels)
98+
maps.Copy(mergedLabels, extraLabels)
99+
100+
return mergedLabels
101+
}
102+
103+
func (p Config) ValidateLabels() error {
104+
105+
var labels = make(map[string]int)
106+
var keys = make(map[string]int)
107+
for _, cfg := range p.PgBouncers {
108+
109+
var slabels []string
110+
111+
for k, v := range p.MergedExtraLabels(cfg.ExtraLabels) {
112+
slabels = append(slabels, fmt.Sprintf("%s=%s", k, v))
113+
keys[k]++
114+
}
115+
slices.Sort(slabels)
116+
hash := strings.Join(slabels, ",")
117+
if _, ok := labels[hash]; ok {
118+
return fmt.Errorf("Every pgbouncer instance must have unique label values,"+
119+
" found the following label=value combination multiple times: '%s'", hash)
120+
}
121+
labels[hash] = 1
122+
}
123+
124+
for k, amount := range keys {
125+
if amount != len(p.PgBouncers) {
126+
return fmt.Errorf("Every pgbouncer instance must define the same extra labels,"+
127+
" the label '%s' is only found on %d of the %d instances", k, amount, len(p.PgBouncers))
128+
}
129+
}
130+
131+
return nil
132+
133+
}

Diff for: config.yaml

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
## must_connect_on_startup: true|false; Default: true
2+
## If true the exporter will fail to start if any connection fails to connect within the startup fase.
3+
## If false the exporter will start even if some connections fail.
4+
must_connect_on_startup: false
5+
6+
## extra_labels: map of label:value; Default: empty
7+
## These common extra labels will be set on all metrics for all connections. The value can be overridden per connection
8+
## Note: Every connection MUST have the same set of labels but a unique set of values.
9+
extra_labels:
10+
environment: test
11+
12+
## metrics_path: /path/for/metrics; Default: /metrics
13+
metrics_path: /metrics
14+
15+
## All the PGBouncers to scrape,
16+
## when multiple connections are used, extra_labels is required to give every connection a unique set of label values
17+
pgbouncers:
18+
- dsn: postgres://postgres:@localhost:6543/pgbouncer?sslmode=disable # Connection string for the pgbouncer instance (Required)
19+
pid-file: /path/to/pidfile # Add path to pgbouncer pid file to enable the process exporter metrics, Default: empty
20+
extra_labels: # Extra labels to identify the metrics for each instance. As mentioned
21+
pgbouncer_instance: set1-0 # Example: a unique identifier for each pgbouncer instance.
22+
environment: prod # Example: a shared label for multiple pgbouncer instances
23+
- dsn: postgres://postgres:@localhost:6544/pgbouncer?sslmode=disable
24+
pid-file:
25+
extra_labels:
26+
pgbouncer_instance: set1-1
27+
environment: prod
28+
- dsn: postgres://postgres:@localhost:6545/pgbouncer?sslmode=disable
29+
pid-file:
30+
extra_labels:
31+
pgbouncer_instance: set2-0
32+
## the metrics of this instance will have the additional labels: {environment: "test", pgbouncer_instance: "set2-0"}
33+
## as `environment: "test"` is inherited from common extra_labels

Diff for: config_test.go

+156
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Copyright 2024 The Prometheus Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific langu
12+
package main
13+
14+
import (
15+
"errors"
16+
"github.com/google/go-cmp/cmp"
17+
"io/fs"
18+
"maps"
19+
"strings"
20+
"testing"
21+
)
22+
23+
func TestDefaultConfig(t *testing.T) {
24+
25+
config := NewDefaultConfig()
26+
27+
MustConnectOnStartupWant := true
28+
if config.MustConnectOnStartup != MustConnectOnStartupWant {
29+
t.Errorf("MustConnectOnStartup does not match. Want: %v, Got: %v", MustConnectOnStartupWant, config.MustConnectOnStartup)
30+
}
31+
32+
MetricsPathWant := "/metrics"
33+
if config.MetricsPath != MetricsPathWant {
34+
t.Errorf("MustConnectOnStartup does not match. Want: %v, Got: %v", MetricsPathWant, config.MustConnectOnStartup)
35+
}
36+
37+
}
38+
39+
func TestUnHappyFileConfig(t *testing.T) {
40+
41+
var config *Config
42+
var err error
43+
44+
config, err = NewConfigFromFile("")
45+
if config != nil || err != nil {
46+
t.Errorf("NewConfigFromFile should return nil for config and error if path is empty. Got: %v", err)
47+
}
48+
49+
_, err = NewConfigFromFile("./testdata/i-do-not-exist.yaml")
50+
if errors.Is(err, fs.ErrNotExist) == false {
51+
t.Errorf("NewConfigFromFile should return fs.ErrNotExist error. Got: %v", err)
52+
}
53+
54+
_, err = NewConfigFromFile("./testdata/parse_error.yaml")
55+
if err != nil && strings.Contains(err.Error(), "yaml: line") == false {
56+
t.Errorf("NewConfigFromFile should return yaml parse error. Got: %v", err)
57+
}
58+
59+
_, err = NewConfigFromFile("./testdata/empty.yaml")
60+
if errors.Is(err, ErrNoPgbouncersConfigured) == false {
61+
t.Errorf("NewConfigFromFile should return ErrNoPgbouncersConfigured error. Got: %v", err)
62+
}
63+
64+
_, err = NewConfigFromFile("./testdata/no-dsn.yaml")
65+
if errors.Is(err, ErrEmptyPgbouncersDSN) == false {
66+
t.Errorf("NewConfigFromFile should return ErrEmptyPgbouncersDSN error. Got: %v", err)
67+
}
68+
69+
}
70+
71+
func TestFileConfig(t *testing.T) {
72+
73+
var config *Config
74+
var err error
75+
76+
config, err = NewConfigFromFile("./testdata/config.yaml")
77+
if err != nil {
78+
t.Errorf("NewConfigFromFile() should not throw an error: %v", err)
79+
}
80+
81+
MustConnectOnStartupWant := false
82+
if config.MustConnectOnStartup != MustConnectOnStartupWant {
83+
t.Errorf("MustConnectOnStartup does not match. Want: %v, Got: %v", MustConnectOnStartupWant, config.MustConnectOnStartup)
84+
}
85+
86+
MetricsPathWant := "/prom"
87+
if config.MetricsPath != MetricsPathWant {
88+
t.Errorf("MustConnectOnStartup does not match. Want: %v, Got: %v", MetricsPathWant, config.MustConnectOnStartup)
89+
}
90+
91+
CommonExtraLabelsWant := map[string]string{"environment": "sandbox"}
92+
if maps.Equal(config.ExtraLabels, CommonExtraLabelsWant) == false {
93+
t.Errorf("ExtraLabels does not match. Want: %v, Got: %v", CommonExtraLabelsWant, config.ExtraLabels)
94+
}
95+
96+
pgWants := []PgBouncerConfig{
97+
{
98+
DSN: "postgres://postgres:@localhost:6543/pgbouncer?sslmode=disable",
99+
PidFile: "/var/run/pgbouncer1.pid",
100+
ExtraLabels: map[string]string{"pgbouncer_instance": "set1-0", "environment": "prod"},
101+
},
102+
{
103+
DSN: "postgres://postgres:@localhost:6544/pgbouncer?sslmode=disable",
104+
PidFile: "/var/run/pgbouncer2.pid",
105+
ExtraLabels: map[string]string{"pgbouncer_instance": "set1-1", "environment": "prod"},
106+
},
107+
}
108+
109+
for i := range pgWants {
110+
if cmp.Equal(config.PgBouncers[i], pgWants[i]) == false {
111+
t.Errorf("PGBouncer config %d does not match. Want: %v, Got: %v", i, pgWants[i], config.PgBouncers[i])
112+
}
113+
}
114+
115+
err = config.ValidateLabels()
116+
if err != nil {
117+
t.Errorf("ValidateLabels() throws an unexpected error: %v", err)
118+
}
119+
120+
}
121+
122+
func TestNotUniqueLabels(t *testing.T) {
123+
124+
config := NewDefaultConfig()
125+
126+
config.AddPgbouncerConfig("", "", map[string]string{"pgbouncer_instance": "set1-0", "environment": "prod"})
127+
config.AddPgbouncerConfig("", "", map[string]string{"pgbouncer_instance": "set1-0", "environment": "prod"})
128+
129+
err := config.ValidateLabels()
130+
if err == nil {
131+
t.Errorf("ValidateLabels() did not throw an error ")
132+
}
133+
errorWant := "Every pgbouncer instance must have unique label values, found the following label=value combination multiple times: 'environment=prod,pgbouncer_instance=set1-0'"
134+
if err.Error() != errorWant {
135+
t.Errorf("ValidateLabels() did not throw the expected error.\n- Want: %s\n- Got: %s", errorWant, err.Error())
136+
}
137+
138+
}
139+
140+
func TestMissingLabels(t *testing.T) {
141+
142+
config := NewDefaultConfig()
143+
144+
config.AddPgbouncerConfig("", "", map[string]string{"pgbouncer_instance": "set1-0", "environment": "prod"})
145+
config.AddPgbouncerConfig("", "", map[string]string{"pgbouncer_instance": "set1-0"})
146+
147+
err := config.ValidateLabels()
148+
if err == nil {
149+
t.Errorf("ValidateLabels() did not throw an error ")
150+
}
151+
errorWant := "Every pgbouncer instance must define the same extra labels, the label 'environment' is only found on 1 of the 2 instances"
152+
if err.Error() != errorWant {
153+
t.Errorf("ValidateLabels() did not throw the expected error.\n- Want: %s\n- Got: %s", errorWant, err.Error())
154+
}
155+
156+
}

0 commit comments

Comments
 (0)