Skip to content

Commit

Permalink
Merge pull request #618 from sysadmind/multi-target
Browse files Browse the repository at this point in the history
Add multi-target support
  • Loading branch information
SuperQ committed Jul 29, 2022
2 parents 58cc383 + 72430f8 commit e552a37
Show file tree
Hide file tree
Showing 16 changed files with 494 additions and 171 deletions.
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,36 @@ docker run \
quay.io/prometheuscommunity/postgres-exporter
```

## Multi-Target Support (BETA)
**This Feature is in beta and may require changes in future releases. Feedback is welcome.**

This exporter supports the [multi-target pattern](https://prometheus.io/docs/guides/multi-target-exporter/). This allows running a single instance of this exporter for multiple postgres targets. Using the milti-target funcationality of this exporter is **optional** and meant for users where it is impossible to install the exporter as a sidecar. For example SaaS-managed services.

To use the multi-target functionality, send an http request to the endpoint `/probe?target=foo:5432` where target is set to the DSN of the postgres instance to scrape metrics from.

To avoid putting sensitive information like username and password in the URL, preconfigured auth modules are supported via the [auth_modules](#auth_modules) section of the config file. auth_modules for DSNs can be used with the `/probe` endpoint by specifying the `?auth_module=foo` http parameter.

## Configuration File

The configuration file controls the behavior of the exporter. It can be set using the `--config.file` command line flag and defaults to `postgres_exporter.yml`.

### auth_modules
This section defines preset authentication and connection parameters for use in the [multi-target endpoint](#multi-target-support-beta). `auth_modules` is a map of modules with the key being the identifier which can be used in the `/probe` endpoint.
Currently only the `userpass` type is supported.

Example:
```yaml
auth_modules:
foo1: # Set this to any name you want
type: userpass
userpass:
username: first
password: firstpass
options:
# options become key=value parameters of the DSN
sslmode: disable
```

## Building and running

git clone https://github.com/prometheus-community/postgres_exporter.git
Expand Down
6 changes: 6 additions & 0 deletions cmd/postgres_exporter/datasource.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@ func getDataSources() ([]string, error) {
uri = os.Getenv("DATA_SOURCE_URI")
}

// No datasources found. This allows us to support the multi-target pattern
// withouth an explicit datasource.
if uri == "" {
return []string{}, nil
}

dsn = "postgresql://" + ui + "@" + uri

return []string{dsn}, nil
Expand Down
32 changes: 23 additions & 9 deletions cmd/postgres_exporter/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/go-kit/log"
"github.com/go-kit/log/level"
"github.com/prometheus-community/postgres_exporter/collector"
"github.com/prometheus-community/postgres_exporter/config"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/prometheus/common/promlog"
Expand All @@ -31,6 +32,11 @@ import (
)

var (
c = config.ConfigHandler{
Config: &config.Config{},
}

configFile = kingpin.Flag("config.file", "Postgres exporter configuration file.").Default("postgres_exporter.yml").String()
listenAddress = kingpin.Flag("web.listen-address", "Address to listen on for web interface and telemetry.").Default(":9187").Envar("PG_EXPORTER_WEB_LISTEN_ADDRESS").String()
webConfig = webflag.AddFlags(kingpin.CommandLine)
metricPath = kingpin.Flag("web.telemetry-path", "Path under which to expose metrics.").Default("/metrics").Envar("PG_EXPORTER_WEB_TELEMETRY_PATH").String()
Expand Down Expand Up @@ -85,14 +91,14 @@ func main() {
return
}

dsn, err := getDataSources()
if err != nil {
level.Error(logger).Log("msg", "Failed reading data sources", "err", err.Error())
os.Exit(1)
if err := c.ReloadConfig(*configFile, logger); err != nil {
// This is not fatal, but it means that auth must be provided for every dsn.
level.Error(logger).Log("msg", "Error loading config", "err", err)
}

if len(dsn) == 0 {
level.Error(logger).Log("msg", "Couldn't find environment variables describing the datasource to use")
dsns, err := getDataSources()
if err != nil {
level.Error(logger).Log("msg", "Failed reading data sources", "err", err.Error())
os.Exit(1)
}

Expand All @@ -106,7 +112,7 @@ func main() {
IncludeDatabases(*includeDatabases),
}

exporter := NewExporter(dsn, opts...)
exporter := NewExporter(dsns, opts...)
defer func() {
exporter.servers.Close()
}()
Expand All @@ -115,23 +121,31 @@ func main() {

prometheus.MustRegister(exporter)

// TODO(@sysadmind): Remove this with multi-target support. We are removing multiple DSN support
dsn := ""
if len(dsns) > 0 {
dsn = dsns[0]
}

pe, err := collector.NewPostgresCollector(
logger,
dsn,
[]string{},
)
if err != nil {
level.Error(logger).Log("msg", "Failed to create PostgresCollector", "err", err.Error())
os.Exit(1)
} else {
prometheus.MustRegister(pe)
}
prometheus.MustRegister(pe)

http.Handle(*metricPath, promhttp.Handler())
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=UTF-8") // nolint: errcheck
w.Write(landingPage) // nolint: errcheck
})

http.HandleFunc("/probe", handleProbe(logger))

level.Info(logger).Log("msg", "Listening on address", "address", *listenAddress)
srv := &http.Server{Addr: *listenAddress}
if err := web.ListenAndServe(srv, *webConfig, logger); err != nil {
Expand Down
105 changes: 105 additions & 0 deletions cmd/postgres_exporter/probe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Copyright 2022 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 (
"fmt"
"net/http"
"time"

"github.com/go-kit/log"
"github.com/go-kit/log/level"
"github.com/prometheus-community/postgres_exporter/collector"
"github.com/prometheus-community/postgres_exporter/config"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)

func handleProbe(logger log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
conf := c.GetConfig()
params := r.URL.Query()
target := params.Get("target")
if target == "" {
http.Error(w, "target is required", http.StatusBadRequest)
return
}
var authModule config.AuthModule
authModuleName := params.Get("auth_module")
if authModuleName == "" {
level.Info(logger).Log("msg", "no auth_module specified, using default")
} else {
var ok bool
authModule, ok = conf.AuthModules[authModuleName]
if !ok {
http.Error(w, fmt.Sprintf("auth_module %s not found", authModuleName), http.StatusBadRequest)
return
}
if authModule.UserPass.Username == "" || authModule.UserPass.Password == "" {
http.Error(w, fmt.Sprintf("auth_module %s has no username or password", authModuleName), http.StatusBadRequest)
return
}
}

dsn, err := authModule.ConfigureTarget(target)
if err != nil {
level.Error(logger).Log("msg", "failed to configure target", "err", err)
http.Error(w, fmt.Sprintf("could not configure dsn for target: %v", err), http.StatusBadRequest)
return
}

// TODO(@sysadmind): Timeout

probeSuccessGauge := prometheus.NewGauge(prometheus.GaugeOpts{
Name: "probe_success",
Help: "Displays whether or not the probe was a success",
})
probeDurationGauge := prometheus.NewGauge(prometheus.GaugeOpts{
Name: "probe_duration_seconds",
Help: "Returns how long the probe took to complete in seconds",
})

tl := log.With(logger, "target", target)

start := time.Now()
registry := prometheus.NewRegistry()
registry.MustRegister(probeSuccessGauge)
registry.MustRegister(probeDurationGauge)

// Run the probe
pc, err := collector.NewProbeCollector(tl, registry, dsn)
if err != nil {
probeSuccessGauge.Set(0)
probeDurationGauge.Set(time.Since(start).Seconds())
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

// TODO(@sysadmind): Remove the registry.MustRegister() call below and instead handle the collection here. That will allow
// for the passing of context, handling of timeouts, and more control over the collection.
// The current NewProbeCollector() implementation relies on the MustNewConstMetric() call to create the metrics which is not
// ideal to use without the registry.MustRegister() call.
_ = ctx

registry.MustRegister(pc)

duration := time.Since(start).Seconds()
probeDurationGauge.Set(duration)

// TODO check success, etc
h := promhttp.HandlerFor(registry, promhttp.HandlerOpts{})
h.ServeHTTP(w, r)
}
}
42 changes: 16 additions & 26 deletions collector/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ package collector

import (
"context"
"database/sql"
"errors"
"fmt"
"sync"
Expand Down Expand Up @@ -58,7 +59,7 @@ var (
)

type Collector interface {
Update(ctx context.Context, server *server, ch chan<- prometheus.Metric) error
Update(ctx context.Context, db *sql.DB, ch chan<- prometheus.Metric) error
}

func registerCollector(name string, isDefaultEnabled bool, createFunc func(logger log.Logger) (Collector, error)) {
Expand Down Expand Up @@ -86,13 +87,13 @@ type PostgresCollector struct {
Collectors map[string]Collector
logger log.Logger

servers map[string]*server
db *sql.DB
}

type Option func(*PostgresCollector) error

// NewPostgresCollector creates a new PostgresCollector.
func NewPostgresCollector(logger log.Logger, dsns []string, filters []string, options ...Option) (*PostgresCollector, error) {
func NewPostgresCollector(logger log.Logger, dsn string, filters []string, options ...Option) (*PostgresCollector, error) {
p := &PostgresCollector{
logger: logger,
}
Expand Down Expand Up @@ -136,17 +137,18 @@ func NewPostgresCollector(logger log.Logger, dsns []string, filters []string, op

p.Collectors = collectors

servers := make(map[string]*server)
for _, dsn := range dsns {
s, err := makeServer(dsn)
if err != nil {
return nil, err
}
if dsn == "" {
return nil, errors.New("empty dsn")
}

servers[dsn] = s
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)

p.servers = servers
p.db = db

return p, nil
}
Expand All @@ -160,32 +162,20 @@ func (p PostgresCollector) Describe(ch chan<- *prometheus.Desc) {
// Collect implements the prometheus.Collector interface.
func (p PostgresCollector) Collect(ch chan<- prometheus.Metric) {
ctx := context.TODO()
wg := sync.WaitGroup{}
wg.Add(len(p.servers))
for _, s := range p.servers {
go func(s *server) {
p.subCollect(ctx, s, ch)
wg.Done()
}(s)
}
wg.Wait()
}

func (p PostgresCollector) subCollect(ctx context.Context, server *server, ch chan<- prometheus.Metric) {
wg := sync.WaitGroup{}
wg.Add(len(p.Collectors))
for name, c := range p.Collectors {
go func(name string, c Collector) {
execute(ctx, name, c, server, ch, p.logger)
execute(ctx, name, c, p.db, ch, p.logger)
wg.Done()
}(name, c)
}
wg.Wait()
}

func execute(ctx context.Context, name string, c Collector, s *server, ch chan<- prometheus.Metric, logger log.Logger) {
func execute(ctx context.Context, name string, c Collector, db *sql.DB, ch chan<- prometheus.Metric, logger log.Logger) {
begin := time.Now()
err := c.Update(ctx, s, ch)
err := c.Update(ctx, db, ch)
duration := time.Since(begin)
var success float64

Expand Down
11 changes: 4 additions & 7 deletions collector/pg_database.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ package collector

import (
"context"
"database/sql"

"github.com/go-kit/log"
"github.com/prometheus/client_golang/prometheus"
Expand All @@ -36,15 +37,11 @@ var pgDatabase = map[string]*prometheus.Desc{
"size_bytes": prometheus.NewDesc(
"pg_database_size_bytes",
"Disk space used by the database",
[]string{"datname", "server"}, nil,
[]string{"datname"}, nil,
),
}

func (PGDatabaseCollector) Update(ctx context.Context, server *server, ch chan<- prometheus.Metric) error {
db, err := server.GetDB()
if err != nil {
return err
}
func (PGDatabaseCollector) Update(ctx context.Context, db *sql.DB, ch chan<- prometheus.Metric) error {
rows, err := db.QueryContext(ctx,
`SELECT pg_database.datname
,pg_database_size(pg_database.datname)
Expand All @@ -63,7 +60,7 @@ func (PGDatabaseCollector) Update(ctx context.Context, server *server, ch chan<-

ch <- prometheus.MustNewConstMetric(
pgDatabase["size_bytes"],
prometheus.GaugeValue, float64(size), datname, server.GetName(),
prometheus.GaugeValue, float64(size), datname,
)
}
if err := rows.Err(); err != nil {
Expand Down

0 comments on commit e552a37

Please sign in to comment.