Skip to content

Add supporting multi-target dynamic scraping #1120

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,31 @@ The labels are:
- rs_state: Replicaset state is an integer from `getDiagnosticData()` -> `replSetGetStatus.myState`.
Check [the official documentation](https://docs.mongodb.com/manual/reference/replica-states/) for details on replicaset status values.

#### Prometheus Configuration to Scrape Multiple MongoDB Hosts
The Prometheus documentation [provides](https://prometheus.io/docs/guides/multi-target-exporter/) a good example of multi-target exporters.

To use `mongodb_exporter` in multi-target mode, you can use the `/scrape` endpoint with the `target` parameter.

You can optionally specify initial URIs using `--mongodb.uri` (or `MONGODB_URI`) to preload a set of MongoDB instances, but it is not required. Additional targets can still be queried dynamically via `/scrape?target=...`.

This allows combining static and dynamic target discovery in a flexible way.
```
scrape_configs:
- job_name: 'mongodb_exporter_targets'
metrics_path: /scrape
static_configs:
- targets:
- mongodb://mongo-host1:27017
- mongo-host2:27017
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: <<MONGODB-EXPORTER-HOSTNAME>>:9216
```

## Usage Reference

See the [Reference Guide](REFERENCE.md) for details on using the exporter.
Expand Down
130 changes: 130 additions & 0 deletions exporter/build_uri.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// mongodb_exporter
// Copyright (C) 2025 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"
"log"
"log/slog"
"net/url"
"regexp"
"strings"
)

func ParseURIList(uriList []string, logger *slog.Logger, splitCluster bool) []string { //nolint:gocognit,cyclop
var URIs []string

// If server URI is prefixed with mongodb scheme string, then every next URI in
// line not prefixed with mongodb scheme string is a part of cluster. Otherwise,
// treat it as a standalone server
realURI := ""
matchRegexp := regexp.MustCompile(`^mongodb(\+srv)?://`)
for _, URI := range uriList {
matches := matchRegexp.FindStringSubmatch(URI)
if matches != nil {
if realURI != "" {
// Add the previous host buffer to the url list as we met the scheme part
URIs = append(URIs, realURI)
realURI = ""
}
if matches[1] == "" {
realURI = URI
} else {
// There can be only one host in SRV connection string
if splitCluster {
// In splitCluster mode we get srv connection string from SRV recors
URI = GetSeedListFromSRV(URI, logger)
}
URIs = append(URIs, URI)
}
} else {
if realURI == "" {
URIs = append(URIs, "mongodb://"+URI)
} else {
realURI += "," + URI
}
}
}
if realURI != "" {
URIs = append(URIs, realURI)
}

if splitCluster {
// In this mode we split cluster strings into separate targets
separateURIs := []string{}
for _, hosturl := range URIs {
urlParsed, err := url.Parse(hosturl)
if err != nil {
log.Fatalf("Failed to parse URI %s: %v", hosturl, err)
}
for _, host := range strings.Split(urlParsed.Host, ",") {
targetURI := "mongodb://"
if urlParsed.User != nil {
targetURI += urlParsed.User.String() + "@"
}
targetURI += host
if urlParsed.Path != "" {
targetURI += urlParsed.Path
}
if urlParsed.RawQuery != "" {
targetURI += "?" + urlParsed.RawQuery
}
separateURIs = append(separateURIs, targetURI)
}
}
return separateURIs
}
return URIs
}

// buildURIManually builds the URI manually by checking if the user and password are supplied
func buildURIManually(uri string, user string, password string) string {
uriArray := strings.SplitN(uri, "://", 2) //nolint:mnd
prefix := uriArray[0] + "://"
uri = uriArray[1]

// 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 != "" {
// add user and pass to the uri
uri = fmt.Sprintf("%s:%s@%s", user, password, uri)
}

// add back prefix after adding the user and pass
uri = prefix + uri

return uri
}

func BuildURI(uri string, user string, password string) string {
defaultPrefix := "mongodb://" // default prefix

if !strings.HasPrefix(uri, defaultPrefix) && !strings.HasPrefix(uri, "mongodb+srv://") {
uri = defaultPrefix + uri
}
parsedURI, err := url.Parse(uri)
if err != nil {
// PMM generates URI with escaped path to socket file, so url.Parse fails
// in this case we build URI manually
return buildURIManually(uri, user, password)
}

if parsedURI.User == nil && user != "" && password != "" {
parsedURI.User = url.UserPassword(user, password)
}

return parsedURI.String()
}
2 changes: 2 additions & 0 deletions exporter/exporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ type Opts struct {

URI string
NodeName string
User string
Password string
}

var (
Expand Down
15 changes: 14 additions & 1 deletion exporter/multi_target_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"net/http"
"net/http/httptest"
"regexp"
"sync"
"testing"

"github.com/prometheus/common/promslog"
Expand Down Expand Up @@ -69,9 +70,21 @@ func TestMultiTarget(t *testing.T) {
"mongodb_up{cluster_role=\"\"} 0\n",
}

exportersCache := make(map[string]*Exporter)
var cacheMutex sync.Mutex

for _, e := range exporters {
cacheMutex.Lock()
exportersCache[e.opts.URI] = e
cacheMutex.Unlock()
}

// Test all targets
for sn, opt := range opts {
assert.HTTPBodyContains(t, multiTargetHandler(serverMap), "GET", fmt.Sprintf("?target=%s", opt.URI), nil, expected[sn])
t.Run(fmt.Sprintf("target_%d", sn), func(t *testing.T) {
handler := multiTargetHandler(serverMap, opt, exportersCache, &cacheMutex, log)
assert.HTTPBodyContains(t, handler, "GET", fmt.Sprintf("?target=%s", opt.URI), nil, expected[sn])
})
}
}

Expand Down
111 changes: 91 additions & 20 deletions exporter/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import (
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"

"github.com/prometheus/client_golang/prometheus"
Expand All @@ -44,18 +44,48 @@ type ServerOpts struct {
}

// RunWebServer runs the main web-server
func RunWebServer(opts *ServerOpts, exporters []*Exporter, log *slog.Logger) {
func RunWebServer(opts *ServerOpts, exporters []*Exporter, exporterOpts *Opts, log *slog.Logger) {
mux := http.NewServeMux()
serverMap := buildServerMap(exporters, log)

exportersCache := make(map[string]*Exporter)
var cacheMutex sync.Mutex

if len(exporters) == 0 {
panic("No exporters were built. You must specify --mongodb.uri command argument or MONGODB_URI environment variable")
// Prefill cache with existing exporters
for _, exp := range exporters {
cacheMutex.Lock()
cacheKey := exp.opts.URI
exportersCache[cacheKey] = exp
cacheMutex.Unlock()
}

serverMap := buildServerMap(exporters, log)
mux.HandleFunc(opts.Path, func(w http.ResponseWriter, r *http.Request) {
targetHost := r.URL.Query().Get("target")

if targetHost == "" {
if len(exporters) > 0 {
defaultExporter := exporters[0]
defaultExporter.Handler().ServeHTTP(w, r)
return
}

cacheMutex.Lock()
defer cacheMutex.Unlock()
for _, exp := range exportersCache {
exp.Handler().ServeHTTP(w, r)
return
}

defaultExporter := exporters[0]
mux.Handle(opts.Path, defaultExporter.Handler())
mux.HandleFunc(opts.MultiTargetPath, multiTargetHandler(serverMap))
reg := prometheus.NewRegistry()
h := promhttp.HandlerFor(reg, promhttp.HandlerOpts{})
h.ServeHTTP(w, r)
return
}

multiTargetHandler(serverMap, exporterOpts, exportersCache, &cacheMutex, log).ServeHTTP(w, r)
})

mux.HandleFunc(opts.MultiTargetPath, multiTargetHandler(serverMap, exporterOpts, exportersCache, &cacheMutex, log))
mux.HandleFunc(opts.OverallTargetPath, OverallTargetsHandler(exporters, log))

mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -85,21 +115,62 @@ func RunWebServer(opts *ServerOpts, exporters []*Exporter, log *slog.Logger) {
}
}

func multiTargetHandler(serverMap ServerMap) http.HandlerFunc {
// multiTargetHandler returns a handler that scrapes metrics from a target specified by the 'target' query parameter.
// It validates the URI and caches dynamic exporters by target.
func multiTargetHandler(serverMap ServerMap, exporterOpts *Opts, exportersCache map[string]*Exporter, cacheMutex *sync.Mutex, logger *slog.Logger) 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
}
}
if targetHost == "" {
logger.Warn("Missing target parameter")
http.Error(w, "Missing target parameter", http.StatusBadRequest)
return
}
http.Error(w, "Unable to find target", http.StatusNotFound)

parsed, err := url.Parse(targetHost)
if err != nil {
logger.Warn("Invalid target parameter", "target", targetHost, "error", err)
http.Error(w, "Invalid target parameter", http.StatusBadRequest)
return
}

fullURI := targetHost
if parsed.User == nil && exporterOpts.User != "" {
fullURI = BuildURI(targetHost, exporterOpts.User, exporterOpts.Password)
}

uri, err := url.Parse(fullURI)
if err != nil {
logger.Warn("Invalid full URI", "target", targetHost, "error", err)
http.Error(w, "Invalid target parameter", http.StatusBadRequest)
return
}

if handler, ok := serverMap[uri.Host]; ok {
logger.Debug("Serving from static serverMap", "host", uri.Host)
handler.ServeHTTP(w, r)
return
}

cacheMutex.Lock()
exp, ok := exportersCache[fullURI]
cacheMutex.Unlock()

if !ok {
logger.Info("Creating new exporter for target", "target", targetHost)
opts := *exporterOpts
opts.URI = fullURI
opts.Logger = logger

exp = New(&opts)

cacheMutex.Lock()
exportersCache[fullURI] = exp
cacheMutex.Unlock()
} else {
logger.Debug("Serving from cache", "target", targetHost)
}

exp.Handler().ServeHTTP(w, r)
}
}

Expand Down
Loading