Skip to content

Bridging Prometheus

Code instrumented with client_golang joins systemd's report framework with two lines: wrap the registry in prombridge.New and register it on a provider. The same registry can keep serving /metrics.

p, err := provider.New("io.example.Api")
if err != nil {
    return err
}
p.Register(prombridge.New(prometheus.DefaultGatherer))
go p.Serve(ctx)

prombridge.New takes any prometheus.Gatherer: a *prometheus.Registry, prometheus.DefaultGatherer, or a prometheus.Gatherers list. Each List or Describe call gathers the registry once, so values are always current.

A complete example

examples/prometheus-bridge is an HTTP service instrumented with client_golang that publishes its registry through a provider:

examples/prometheus-bridge/main.go
// Command prometheus-bridge is an HTTP service instrumented with the
// Prometheus client library that also publishes its metrics through
// systemd's report framework, without re-instrumenting: the provider reads
// the same registry the /metrics handler serves.
//
//  go run ./examples/prometheus-bridge -dir /tmp/report
//  curl -s localhost:8080/hello
//  go run ./cmd/sdmetrics-exporter -once -dir /tmp/report
package main

import (
    "context"
    "errors"
    "flag"
    "fmt"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"

    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/collectors"
    "github.com/prometheus/client_golang/prometheus/promhttp"

    "github.com/rossedman/sdmetrics/provider"
    "github.com/rossedman/sdmetrics/provider/prombridge"
)

func main() {
    dir := flag.String("dir", provider.DefaultDir, "report directory")
    addr := flag.String("listen", "127.0.0.1:8080", "HTTP address")
    flag.Parse()

    // Existing instrumentation, unchanged.
    reg := prometheus.NewRegistry()
    reg.MustRegister(collectors.NewGoCollector())
    requests := prometheus.NewCounterVec(prometheus.CounterOpts{
        Name: "http_requests_total", Help: "HTTP requests served",
    }, []string{"path", "code"})
    latency := prometheus.NewHistogram(prometheus.HistogramOpts{
        Name: "http_request_duration_seconds", Help: "HTTP request latency", Buckets: prometheus.DefBuckets,
    })
    reg.MustRegister(requests, latency)

    mux := http.NewServeMux()
    mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
        t := prometheus.NewTimer(latency)
        defer t.ObserveDuration()
        requests.WithLabelValues(r.URL.Path, "200").Inc()
        fmt.Fprintln(w, "hello")
    })
    mux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))

    // The bridge: two lines.
    p, err := provider.New("io.example.Api", provider.WithDir(*dir), provider.WithSocketMode(0o666))
    if err != nil {
        log.Fatal(err)
    }
    p.Register(prombridge.New(reg))

    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()
    go func() {
        if err := p.Serve(ctx); err != nil {
            log.Fatal(err)
        }
    }()

    srv := &http.Server{Addr: *addr, Handler: mux}
    go func() { <-ctx.Done(); srv.Close() }()
    log.Printf("serving http://%s/hello and %s", *addr, p.Path())
    if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
        log.Fatal(err)
    }
}
go run ./examples/prometheus-bridge -dir /tmp/report &
curl -s localhost:8080/hello
go run ./cmd/sdmetrics-exporter -once -dir /tmp/report -include io.example.Api

How types map

Prometheus systemd families Fields
Counter name counter name labels
Gauge name gauge name labels
Untyped name gauge name labels
Histogram name counter name_bucket, counter name_sum, counter name_count labels; le on buckets
Gauge histogram name the same three, as gauges labels; le on buckets
Summary name gauge name, counter name_sum, counter name_count labels; quantile on name

This is the Prometheus text format's own layout, so dashboards built on the classic histogram series keep working. A +Inf bucket is added when the histogram doesn't report one. Counter values that are whole numbers are published as integers, like systemd's own counters; others keep their fraction.

For the example above, the families are:

io.example.Api.http_requests_total                      counter   {code, path}
io.example.Api.http_request_duration_seconds_bucket     counter   {le}
io.example.Api.http_request_duration_seconds_sum        counter
io.example.Api.http_request_duration_seconds_count      counter
io.example.Api.go_goroutines                            gauge
...

How names map

Prometheus names that are valid Varlink field names (a letter, then letters, digits and single underscores) are kept as they are. Others are sanitized:

Rule Example
Characters other than letters and digits become _ http:requests → http_requests
Runs of _ collapse go__gc → go_gc
Leading and trailing _ are dropped _internal_ → internal
A leading digit gets an m prefix 5xx_total → m5xx_total

Prometheus label names keep their spelling as fields.

With the exporter's naming: otel, io.example.Api.http_requests_total is exported as io.example.api.http_requests and ..._duration_seconds_sum as io.example.api.http_request_duration_seconds_sum. The unit suffix is only recognized as the last word of a name.

Bridge or re-instrument?

The bridge costs one Gather per scrape and keeps your code unchanged. If you are writing new code, or need allocation-free updates on a hot path, register metrics on the provider directly. Both can share one provider.

Next: Bridging OpenTelemetry.