From e02fe4f36c1b18a57fcfe27026cbf84ed0c78611 Mon Sep 17 00:00:00 2001 From: adnull Date: Tue, 10 Oct 2023 23:40:41 +0300 Subject: [PATCH] Multi target support (#653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added multi target feature * added connect timeout opts * added tests * fixed test * fixed dockerfile * Bump github.com/golangci/golangci-lint from 1.47.3 to 1.52.2 in /tools (#639) Bumps [github.com/golangci/golangci-lint](https://github.com/golangci/golangci-lint) from 1.47.3 to 1.52.2. - [Release notes](https://github.com/golangci/golangci-lint/releases) - [Changelog](https://github.com/golangci/golangci-lint/blob/master/CHANGELOG.md) - [Commits](https://github.com/golangci/golangci-lint/compare/v1.47.3...v1.52.2) --- updated-dependencies: - dependency-name: github.com/golangci/golangci-lint dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump github.com/golangci/golangci-lint from 1.52.2 to 1.53.2 in /tools (#665) Bumps [github.com/golangci/golangci-lint](https://github.com/golangci/golangci-lint) from 1.52.2 to 1.53.2. - [Release notes](https://github.com/golangci/golangci-lint/releases) - [Changelog](https://github.com/golangci/golangci-lint/blob/master/CHANGELOG.md) - [Commits](https://github.com/golangci/golangci-lint/compare/v1.52.2...v1.53.2) --- updated-dependencies: - dependency-name: github.com/golangci/golangci-lint dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Remove `prometheus/client_golang` replace (#682) * Remove prometheus/client_golang replace * Apply formater * Downgrade client_golang to previous version * Upgrade exporter-toolkit to v0.10.0 * added multi target feature * added connect timeout opts * fixed connect * formatted code * fixed linter warnings * Update README.md * Update README.md * updated license * Update exporter/server.go Co-authored-by: Nurlan Moldomurov * fixed according to review * formatted the code * minor changes according to the linters --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Marc Tudurí Co-authored-by: Jiří Čtvrtka <62988319+JiriCtvrtka@users.noreply.github.com> Co-authored-by: Nurlan Moldomurov Co-authored-by: Nurlan Moldomurov --- Dockerfile | 15 ++++- README.md | 11 ++++ exporter/base_collector.go | 5 +- exporter/exporter.go | 72 ++++++---------------- exporter/exporter_test.go | 80 ++++++++++++++++++++---- exporter/multi_target_test.go | 72 ++++++++++++++++++++++ exporter/server.go | 111 ++++++++++++++++++++++++++++++++++ internal/tu/testutils.go | 2 + main.go | 108 +++++++++++++++++++++------------ main_test.go | 6 +- 10 files changed, 371 insertions(+), 111 deletions(-) create mode 100644 exporter/multi_target_test.go create mode 100644 exporter/server.go diff --git a/Dockerfile b/Dockerfile index 3ed6f8ffb..759ab614a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,18 @@ FROM alpine AS builder RUN apk add --no-cache ca-certificates -FROM scratch AS final +FROM golang:alpine as builder2 + +RUN apk update && apk add make +RUN mkdir /source +COPY . /source +WORKDIR /source +RUN make init +RUN make build + +FROM alpine AS final USER 65535:65535 -COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ -COPY ./mongodb_exporter / +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=builder2 /source/mongodb_exporter / EXPOSE 9216 ENTRYPOINT ["/mongodb_exporter"] \ No newline at end of file diff --git a/README.md b/README.md index f20cefb7a..6fbd05511 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,17 @@ export MONGODB_PASSWORD=YYY mongodb_exporter_linux_amd64/mongodb_exporter --mongodb.uri=mongodb://127.0.0.1:17001 --mongodb.collstats-colls=db1.c1,db2.c2 ``` +#### Multi-target support +You can run the exporter specifying multiple URIs, devided by a comma in --mongodb.uri option or MONGODB_URI environment variable in order to monitor multiple mongodb instances with the a single mongodb_exporter instance. +```sh +--mongodb.uri=mongodb://user:pass@127.0.0.1:27017/admin,mongodb://user2:pass2@127.0.0.1:27018/admin +``` +In this case you can use the **/scrape** endpoint with the **target** parameter to retreive the specified tartget's metrics. When querying the data you can use just mongodb://host:port in the targer parameter without other parameters and, of course without host credentials +```sh +GET /scrape?target=mongodb://127.0.0.1:27018 +``` + + #### Enabling collstats metrics gathering `--mongodb.collstats-colls` receives a list of databases and collections to monitor using collstats. Usage example: `--mongodb.collstats-colls=database1.collection1,database2.collection2` diff --git a/exporter/base_collector.go b/exporter/base_collector.go index c16dc34d5..4184112d2 100644 --- a/exporter/base_collector.go +++ b/exporter/base_collector.go @@ -43,7 +43,10 @@ func newBaseCollector(client *mongo.Client, logger *logrus.Logger) *baseCollecto func (d *baseCollector) Describe(ctx context.Context, ch chan<- *prometheus.Desc, collect func(mCh chan<- prometheus.Metric)) { select { case <-ctx.Done(): - return + // don't interrupt, let mongodb_up metric to be registered if on timeout we still don't have client connected + if d.client != nil { + return + } default: } diff --git a/exporter/exporter.go b/exporter/exporter.go index 4787abe7a..ce11f0667 100644 --- a/exporter/exporter.go +++ b/exporter/exporter.go @@ -21,15 +21,12 @@ import ( "fmt" "net/http" _ "net/http/pprof" - "os" "strconv" "sync" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" - "github.com/prometheus/common/promlog" - "github.com/prometheus/exporter-toolkit/web" "github.com/sirupsen/logrus" "go.mongodb.org/mongo-driver/mongo" @@ -38,12 +35,10 @@ import ( // Exporter holds Exporter methods and attributes. type Exporter struct { - path string client *mongo.Client clientMu sync.Mutex logger *logrus.Logger opts *Opts - webListenAddress string lock *sync.Mutex totalCollectionsCount int } @@ -56,6 +51,7 @@ type Opts struct { CollStatsLimit int CompatibleMode bool DirectConnect bool + ConnectTimeoutMS int DisableDefaultRegistry bool DiscoveringMode bool GlobalConnPool bool @@ -76,10 +72,8 @@ type Opts struct { IndexStatsCollections []string Logger *logrus.Logger - Path string - URI string - WebListenAddress string - TLSConfigPath string + + URI string } var ( @@ -103,16 +97,9 @@ func New(opts *Opts) *Exporter { ctx := context.Background() - if opts.Path == "" { - opts.Logger.Warn("Web telemetry path \"\" invalid, falling back to \"/\" instead") - opts.Path = "/" - } - exp := &Exporter{ - path: opts.Path, logger: opts.Logger, opts: opts, - webListenAddress: opts.WebListenAddress, lock: &sync.Mutex{}, totalCollectionsCount: -1, // Not calculated yet. waiting the db connection. } @@ -257,7 +244,7 @@ func (e *Exporter) getClient(ctx context.Context) (*mongo.Client, error) { return e.client, nil } - client, err := connect(context.Background(), e.opts.URI, e.opts.DirectConnect) + client, err := connect(context.Background(), e.opts) if err != nil { return nil, err } @@ -267,7 +254,7 @@ func (e *Exporter) getClient(ctx context.Context) (*mongo.Client, error) { } // !e.opts.GlobalConnPool: create new client for every scrape. - client, err := connect(ctx, e.opts.URI, e.opts.DirectConnect) + client, err := connect(ctx, e.opts) if err != nil { return nil, err } @@ -350,14 +337,15 @@ func (e *Exporter) Handler() http.Handler { gatherers = append(gatherers, prometheus.DefaultGatherer) } + var ti *topologyInfo if client != nil { // Topology can change between requests, so we need to get it every time. - ti := newTopologyInfo(ctx, client, e.logger) - - registry := e.makeRegistry(ctx, client, ti, requestOpts) - gatherers = append(gatherers, registry) + ti = newTopologyInfo(ctx, client, e.logger) } + registry := e.makeRegistry(ctx, client, ti, requestOpts) + gatherers = append(gatherers, registry) + // Delegate http serving to Prometheus client library, which will call collector.Collect. h := promhttp.HandlerFor(gatherers, promhttp.HandlerOpts{ ErrorHandling: promhttp.ContinueOnError, @@ -368,41 +356,21 @@ func (e *Exporter) Handler() http.Handler { }) } -// Run starts the exporter. -func (e *Exporter) Run() { - mux := http.DefaultServeMux - mux.Handle(e.path, e.Handler()) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(` - MongoDB Exporter - -

MongoDB Exporter

-

Metrics

- - `)) - }) - - server := &http.Server{ - Handler: mux, - } - flags := &web.FlagConfig{ - WebListenAddresses: &[]string{e.webListenAddress}, - WebConfigFile: &e.opts.TLSConfigPath, - } - if err := web.ListenAndServe(server, flags, promlog.New(&promlog.Config{})); err != nil { - e.logger.Errorf("error starting server: %v", err) - os.Exit(1) - } -} - -func connect(ctx context.Context, dsn string, directConnect bool) (*mongo.Client, error) { - clientOpts, err := dsn_fix.ClientOptionsForDSN(dsn) +func connect(ctx context.Context, opts *Opts) (*mongo.Client, error) { + clientOpts, err := dsn_fix.ClientOptionsForDSN(opts.URI) if err != nil { return nil, fmt.Errorf("invalid dsn: %w", err) } - clientOpts.SetDirect(directConnect) + + clientOpts.SetDirect(opts.DirectConnect) clientOpts.SetAppName("mongodb_exporter") + if clientOpts.ConnectTimeout == nil { + connectTimeout := time.Duration(opts.ConnectTimeoutMS) * time.Millisecond + clientOpts.SetConnectTimeout(connectTimeout) + clientOpts.SetServerSelectionTimeout(connectTimeout) + } + client, err := mongo.Connect(ctx, clientOpts) if err != nil { return nil, fmt.Errorf("invalid MongoDB options: %w", err) diff --git a/exporter/exporter_test.go b/exporter/exporter_test.go index b340c8810..9dba21159 100644 --- a/exporter/exporter_test.go +++ b/exporter/exporter_test.go @@ -19,11 +19,15 @@ import ( "context" "fmt" "io" + "net" "net/http" "net/http/httptest" + "strconv" + "strings" "sync" "testing" + "github.com/prometheus/client_golang/prometheus/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" @@ -61,8 +65,11 @@ func TestConnect(t *testing.T) { t.Run("Connect without SSL", func(t *testing.T) { for name, port := range ports { - dsn := fmt.Sprintf("mongodb://%s:%s/admin", hostname, port) - client, err := connect(ctx, dsn, true) + exporterOpts := &Opts{ + URI: fmt.Sprintf("mongodb://%s/admin", net.JoinHostPort(hostname, port)), + DirectConnect: true, + } + client, err := connect(ctx, exporterOpts) assert.NoError(t, err, name) err = client.Disconnect(ctx) assert.NoError(t, err, name) @@ -167,17 +174,17 @@ func TestMongoS(t *testing.T) { } for _, test := range tests { - dsn := fmt.Sprintf("mongodb://%s:%s/admin", hostname, test.port) - client, err := connect(ctx, dsn, true) - assert.NoError(t, err) - exporterOpts := &Opts{ Logger: logrus.New(), - URI: dsn, + URI: fmt.Sprintf("mongodb://%s/admin", net.JoinHostPort(hostname, test.port)), + DirectConnect: true, GlobalConnPool: false, EnableReplicasetStatus: true, } + client, err := connect(ctx, exporterOpts) + assert.NoError(t, err) + e := New(exporterOpts) rsgsc := newReplicationSetStatusCollector(ctx, client, e.opts.Logger, @@ -195,17 +202,17 @@ func TestMongoS(t *testing.T) { func TestMongoUp(t *testing.T) { ctx := context.Background() - dsn := "mongodb://127.0.0.1:123456/admin" - client, err := connect(ctx, dsn, true) - assert.Error(t, err) - exporterOpts := &Opts{ Logger: logrus.New(), - URI: dsn, + URI: "mongodb://127.0.0.1:123456/admin", + DirectConnect: true, GlobalConnPool: false, CollectAll: true, } + client, err := connect(ctx, exporterOpts) + assert.Error(t, err) + e := New(exporterOpts) gc := newGeneralCollector(ctx, client, e.opts.Logger) @@ -215,3 +222,52 @@ func TestMongoUp(t *testing.T) { res := r.Unregister(gc) assert.Equal(t, true, res) } + +func TestMongoUpMetric(t *testing.T) { + ctx := context.Background() + + type testcase struct { + URI string + Want int + } + + testCases := []testcase{ + {URI: "mongodb://127.0.0.1:12345/admin", Want: 0}, + {URI: fmt.Sprintf("mongodb://127.0.0.1:%s/admin", tu.GetenvDefault("TEST_MONGODB_STANDALONE_PORT", "27017")), Want: 1}, + } + + for _, tc := range testCases { + exporterOpts := &Opts{ + Logger: logrus.New(), + URI: tc.URI, + ConnectTimeoutMS: 200, + DirectConnect: true, + GlobalConnPool: false, + CollectAll: true, + } + + client, err := connect(ctx, exporterOpts) + if tc.Want == 1 { + assert.NoError(t, err, "Must be able to connect to %s", tc.URI) + } else { + assert.Error(t, err, "Must be unable to connect to %s", tc.URI) + } + + e := New(exporterOpts) + gc := newGeneralCollector(ctx, client, e.opts.Logger) + r := e.makeRegistry(ctx, client, new(labelsGetterMock), *e.opts) + + expected := strings.NewReader(` + # HELP mongodb_up Whether MongoDB is up. + # TYPE mongodb_up gauge + mongodb_up ` + strconv.Itoa(tc.Want) + "\n") + filter := []string{ + "mongodb_up", + } + err = testutil.CollectAndCompare(gc, expected, filter...) + assert.NoError(t, err, "mongodb_up metric should be %d", tc.Want) + + res := r.Unregister(gc) + assert.Equal(t, true, res) + } +} diff --git a/exporter/multi_target_test.go b/exporter/multi_target_test.go new file mode 100644 index 000000000..f689ce7c7 --- /dev/null +++ b/exporter/multi_target_test.go @@ -0,0 +1,72 @@ +// mongodb_exporter +// Copyright (C) 2017 Percona LLC +// +// 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 exporter + +import ( + "fmt" + "net" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + + "github.com/percona/mongodb_exporter/internal/tu" +) + +func TestMultiTarget(t *testing.T) { + hostname := "127.0.0.1" + opts := []*Opts{ + { + URI: fmt.Sprintf("mongodb://%s", net.JoinHostPort(hostname, tu.GetenvDefault("TEST_MONGODB_STANDALONE_PORT", "27017"))), + DirectConnect: true, + ConnectTimeoutMS: 1000, + }, + { + URI: fmt.Sprintf("mongodb://%s", net.JoinHostPort(hostname, tu.GetenvDefault("TEST_MONGODB_S1_PRIMARY_PORT", "17001"))), + DirectConnect: true, + ConnectTimeoutMS: 1000, + }, + { + URI: fmt.Sprintf("mongodb://%s", net.JoinHostPort(hostname, tu.GetenvDefault("TEST_MONGODB_S2_PRIMARY_PORT", "17004"))), + DirectConnect: true, + ConnectTimeoutMS: 1000, + }, + { + URI: fmt.Sprintf("mongodb://%s", net.JoinHostPort(hostname, "12345")), + DirectConnect: true, + ConnectTimeoutMS: 1000, + }, + } + exporters := make([]*Exporter, len(opts)) + + for i, opt := range opts { + exporters[i] = New(opt) + } + log := logrus.New() + serverMap := buildServerMap(exporters, log) + + expected := []string{ + "mongodb_up 1\n", + "mongodb_up 1\n", + "mongodb_up 1\n", + "mongodb_up 0\n", + } + + // Test all targets + for sn, opt := range opts { + assert.HTTPBodyContains(t, multiTargetHandler(serverMap), "GET", fmt.Sprintf("?target=%s", opt.URI), nil, expected[sn]) + } +} diff --git a/exporter/server.go b/exporter/server.go new file mode 100644 index 000000000..6788acb31 --- /dev/null +++ b/exporter/server.go @@ -0,0 +1,111 @@ +// mongodb_exporter +// Copyright (C) 2017 Percona LLC +// +// 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 exporter + +import ( + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/prometheus/common/promlog" + "github.com/prometheus/exporter-toolkit/web" + "github.com/sirupsen/logrus" +) + +// ServerMap stores http handlers for each host +type ServerMap map[string]http.Handler + +// ServerOpts is the options for the main http handler +type ServerOpts struct { + Path string + MultiTargetPath string + WebListenAddress string + TLSConfigPath string +} + +// Runs the main web-server +func RunWebServer(opts *ServerOpts, exporters []*Exporter, log *logrus.Logger) { + mux := http.DefaultServeMux + + if len(exporters) == 0 { + panic("No exporters were built. You must specify --mongodb.uri command argument or MONGODB_URI environment variable") + } + + serverMap := buildServerMap(exporters, log) + + defaultExporter := exporters[0] + mux.Handle(opts.Path, defaultExporter.Handler()) + mux.HandleFunc(opts.MultiTargetPath, multiTargetHandler(serverMap)) + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte(` + MongoDB Exporter + +

MongoDB Exporter

+

Metrics

+ + `)) + if err != nil { + log.Errorf("error writing response: %v", err) + } + }) + + server := &http.Server{ + ReadHeaderTimeout: 2 * time.Second, + Handler: mux, + } + flags := &web.FlagConfig{ + WebListenAddresses: &[]string{opts.WebListenAddress}, + WebConfigFile: &opts.TLSConfigPath, + } + if err := web.ListenAndServe(server, flags, promlog.New(&promlog.Config{})); err != nil { + log.Errorf("error starting server: %v", err) + os.Exit(1) + } +} + +func multiTargetHandler(serverMap ServerMap) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + targetHost := r.URL.Query().Get("target") + if targetHost != "" { + if !strings.HasPrefix(targetHost, "mongodb://") { + targetHost = "mongodb://" + targetHost + } + if uri, err := url.Parse(targetHost); err == nil { + if e, ok := serverMap[uri.Host]; ok { + e.ServeHTTP(w, r) + return + } + } + } + http.Error(w, "Unable to find target", http.StatusNotFound) + } +} + +func buildServerMap(exporters []*Exporter, log *logrus.Logger) ServerMap { + servers := make(ServerMap, len(exporters)) + for _, e := range exporters { + if url, err := url.Parse(e.opts.URI); err == nil { + servers[url.Host] = e.Handler() + } else { + log.Errorf("Unable to parse addr %s as url: %s", e.opts.URI, err) + } + } + + return servers +} diff --git a/internal/tu/testutils.go b/internal/tu/testutils.go index ce138ab3f..a93ccdfe1 100644 --- a/internal/tu/testutils.go +++ b/internal/tu/testutils.go @@ -67,6 +67,7 @@ func GetenvDefault(key, defaultValue string) string { func DefaultTestClient(ctx context.Context, t *testing.T) *mongo.Client { port, err := PortForContainer("mongo-1-1") require.NoError(t, err) + return TestClient(ctx, port, t) } @@ -103,6 +104,7 @@ func GetImageNameForDefault() (string, string, error) { break } } + return imageBaseName, version, nil } diff --git a/main.go b/main.go index 1e5d13561..83948ff27 100644 --- a/main.go +++ b/main.go @@ -34,17 +34,18 @@ var ( // GlobalFlags has command line flags to configure the exporter. type GlobalFlags struct { - User string `name:"mongodb.user" help:"monitor user, need clusterMonitor role in admin db and read role in local db" env:"MONGODB_USER" placeholder:"monitorUser"` - Password string `name:"mongodb.password" help:"monitor user password" env:"MONGODB_PASSWORD" placeholder:"monitorPassword"` - CollStatsNamespaces string `name:"mongodb.collstats-colls" help:"List of comma separared databases.collections to get $collStats" placeholder:"db1,db2.col2"` - IndexStatsCollections string `name:"mongodb.indexstats-colls" help:"List of comma separared databases.collections to get $indexStats" placeholder:"db1.col1,db2.col2"` - URI string `name:"mongodb.uri" help:"MongoDB connection URI" env:"MONGODB_URI" placeholder:"mongodb://user:pass@127.0.0.1:27017/admin?ssl=true"` - GlobalConnPool bool `name:"mongodb.global-conn-pool" help:"Use global connection pool instead of creating new pool for each http request." negatable:""` - DirectConnect bool `name:"mongodb.direct-connect" help:"Whether or not a direct connect should be made. Direct connections are not valid if multiple hosts are specified or an SRV URI is used." default:"true" negatable:""` - WebListenAddress string `name:"web.listen-address" help:"Address to listen on for web interface and telemetry" default:":9216"` - WebTelemetryPath string `name:"web.telemetry-path" help:"Metrics expose path" default:"/metrics"` - TLSConfigPath string `name:"web.config" help:"Path to the file having Prometheus TLS config for basic auth"` - LogLevel string `name:"log.level" help:"Only log messages with the given severity or above. Valid levels: [debug, info, warn, error, fatal]" enum:"debug,info,warn,error,fatal" default:"error"` + User string `name:"mongodb.user" help:"monitor user, need clusterMonitor role in admin db and read role in local db" env:"MONGODB_USER" placeholder:"monitorUser"` + Password string `name:"mongodb.password" help:"monitor user password" env:"MONGODB_PASSWORD" placeholder:"monitorPassword"` + CollStatsNamespaces string `name:"mongodb.collstats-colls" help:"List of comma separared databases.collections to get $collStats" placeholder:"db1,db2.col2"` + IndexStatsCollections string `name:"mongodb.indexstats-colls" help:"List of comma separared databases.collections to get $indexStats" placeholder:"db1.col1,db2.col2"` + URI []string `name:"mongodb.uri" help:"MongoDB connection URI" env:"MONGODB_URI" placeholder:"mongodb://user:pass@127.0.0.1:27017/admin?ssl=true"` + GlobalConnPool bool `name:"mongodb.global-conn-pool" help:"Use global connection pool instead of creating new pool for each http request." negatable:""` + DirectConnect bool `name:"mongodb.direct-connect" help:"Whether or not a direct connect should be made. Direct connections are not valid if multiple hosts are specified or an SRV URI is used." default:"true" negatable:""` + WebListenAddress string `name:"web.listen-address" help:"Address to listen on for web interface and telemetry" default:":9216"` + WebTelemetryPath string `name:"web.telemetry-path" help:"Metrics expose path" default:"/metrics"` + TLSConfigPath string `name:"web.config" help:"Path to the file having Prometheus TLS config for basic auth"` + LogLevel string `name:"log.level" help:"Only log messages with the given severity or above. Valid levels: [debug, info, warn, error, fatal]" enum:"debug,info,warn,error,fatal" default:"error"` + ConnectTimeoutMS int `name:"mongodb.connect-timeout-ms" help:"Connection timeout in milliseconds" default:"5000"` EnableDiagnosticData bool `name:"collector.diagnosticdata" help:"Enable collecting metrics from getDiagnosticData"` EnableReplicasetStatus bool `name:"collector.replicasetstatus" help:"Enable collecting metrics from replSetGetStatus"` @@ -71,7 +72,7 @@ type GlobalFlags struct { func main() { var opts GlobalFlags - _ = kong.Parse(&opts, + ctx := kong.Parse(&opts, kong.Name("mongodb_exporter"), kong.Description("MongoDB Prometheus exporter"), kong.UsageOnError(), @@ -87,30 +88,9 @@ func main() { fmt.Printf("Version: %s\n", version) fmt.Printf("Commit: %s\n", commit) fmt.Printf("Build date: %s\n", buildDate) - return } - e := buildExporter(opts) - e.Run() -} - -func buildURI(uri string, user string, password string) string { - // IF user@pass not contained in uri AND custom user and pass supplied in arguments - // DO concat a new uri with user and pass arguments value - if !strings.Contains(uri, "@") && user != "" && password != "" { - // trim mongodb:// prefix to handle user and pass logic - uri = strings.TrimPrefix(uri, "mongodb://") - // add user and pass to the uri - uri = fmt.Sprintf("%s:%s@%s", user, password, uri) - } - if !strings.HasPrefix(uri, "mongodb") { - uri = "mongodb://" + uri - } - return uri -} - -func buildExporter(opts GlobalFlags) *exporter.Exporter { log := logrus.New() levels := map[string]logrus.Level{ @@ -121,12 +101,29 @@ func buildExporter(opts GlobalFlags) *exporter.Exporter { "warn": logrus.WarnLevel, } log.SetLevel(levels[opts.LogLevel]) - log.Debugf("Compatible mode: %v", opts.CompatibleMode) - opts.URI = buildURI(opts.URI, opts.User, opts.Password) + if opts.WebTelemetryPath == "" { + log.Warn("Web telemetry path \"\" invalid, falling back to \"/\" instead") + opts.WebTelemetryPath = "/" + } + + if len(opts.URI) == 0 { + ctx.Fatalf("No MongoDB hosts were specified. You must specify the host(s) with the --mongodb.uri command argument or the MONGODB_URI environment variable") + } + + serverOpts := &exporter.ServerOpts{ + Path: opts.WebTelemetryPath, + MultiTargetPath: "/scrape", + WebListenAddress: opts.WebListenAddress, + TLSConfigPath: opts.TLSConfigPath, + } + exporter.RunWebServer(serverOpts, buildServers(opts, log), log) +} - log.Debugf("Connection URI: %s", opts.URI) +func buildExporter(opts GlobalFlags, uri string, log *logrus.Logger) *exporter.Exporter { + uri = buildURI(uri, opts.User, opts.Password) + log.Debugf("Connection URI: %s", uri) exporterOpts := &exporter.Opts{ CollStatsNamespaces: strings.Split(opts.CollStatsNamespaces, ","), @@ -134,12 +131,10 @@ func buildExporter(opts GlobalFlags) *exporter.Exporter { DiscoveringMode: opts.DiscoveringMode, IndexStatsCollections: strings.Split(opts.IndexStatsCollections, ","), Logger: log, - Path: opts.WebTelemetryPath, - URI: opts.URI, + URI: uri, GlobalConnPool: opts.GlobalConnPool, - WebListenAddress: opts.WebListenAddress, - TLSConfigPath: opts.TLSConfigPath, DirectConnect: opts.DirectConnect, + ConnectTimeoutMS: opts.ConnectTimeoutMS, EnableDiagnosticData: opts.EnableDiagnosticData, EnableReplicasetStatus: opts.EnableReplicasetStatus, @@ -162,3 +157,36 @@ func buildExporter(opts GlobalFlags) *exporter.Exporter { return e } + +func buildServers(opts GlobalFlags, log *logrus.Logger) []*exporter.Exporter { + servers := make([]*exporter.Exporter, len(opts.URI)) + + for serverIdx := range opts.URI { + URI := opts.URI[serverIdx] + + if !strings.HasPrefix(URI, "mongodb") { + log.Debugf("Prepending mongodb:// to the URI %s", URI) + URI = "mongodb://" + URI + } + + servers[serverIdx] = buildExporter(opts, URI, log) + } + + return servers +} + +func buildURI(uri string, user string, password string) string { + // IF user@pass not contained in uri AND custom user and pass supplied in arguments + // DO concat a new uri with user and pass arguments value + if !strings.Contains(uri, "@") && user != "" && password != "" { + // trim mongodb:// prefix to handle user and pass logic + uri = strings.TrimPrefix(uri, "mongodb://") + // add user and pass to the uri + uri = fmt.Sprintf("%s:%s@%s", user, password, uri) + } + if !strings.HasPrefix(uri, "mongodb") { + uri = "mongodb://" + uri + } + + return uri +} diff --git a/main_test.go b/main_test.go index bf37e38a0..a5549e1bb 100644 --- a/main_test.go +++ b/main_test.go @@ -18,6 +18,7 @@ package main import ( "testing" + "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) @@ -25,7 +26,6 @@ func TestBuildExporter(t *testing.T) { opts := GlobalFlags{ CollStatsNamespaces: "c1,c2,c3", IndexStatsCollections: "i1,i2,i3", - URI: "mongodb://usr:pwd@127.0.0.1/", GlobalConnPool: false, // to avoid testing the connection WebListenAddress: "localhost:12345", WebTelemetryPath: "/mymetrics", @@ -36,8 +36,8 @@ func TestBuildExporter(t *testing.T) { CompatibleMode: true, } - - buildExporter(opts) + log := logrus.New() + buildExporter(opts, "mongodb://usr:pwd@127.0.0.1/", log) } func TestBuildURI(t *testing.T) {