// Package expvarom implements an OpenMetrics HTTP exporter for the variables
// from the expvar package.
//
// This is useful for small servers that want to support both packages with
// simple enough variables, without introducing any dependencies beyond the
// standard library.
//
// Some functions to add descriptions and map labels are exported for
// convenience, but their usage is optional.
//
// For more complex usage (like histograms, counters vs. gauges, etc.), use
// the OpenMetrics libraries directly.
//
// The exporter uses the text-based format, as documented in:
// https://prometheus.io/docs/instrumenting/exposition_formats/#text-based-format
// https://github.com/OpenObservability/OpenMetrics/blob/master/specification/OpenMetrics.md
//
// Note the adoption of that format as OpenMetrics' one isn't finalized yet,
// and it is possible that it will change in the future.
//
// Backwards compatibility is NOT guaranteed, until the format is fully
// standarized.
package expvarom
import (
"expvar"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"unicode/utf8"
)
type exportedVar struct {
Name string
Desc string
LabelName string
I *expvar.Int
F *expvar.Float
M *expvar.Map
}
var (
infoMu = sync.Mutex{}
descriptions = map[string]string{}
mapLabelNames = map[string]string{}
)
// MetricsHandler implements an http.HandlerFunc which serves the registered
// metrics, using the OpenMetrics text-based format.
func MetricsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type",
"application/openmetrics-text; version=1.0.0; charset=utf-8")
vars := []exportedVar{}
ignored := []string{}
expvar.Do(func(kv expvar.KeyValue) {
evar := exportedVar{
Name: metricNameToOM(kv.Key),
}
switch value := kv.Value.(type) {
case *expvar.Int:
evar.I = value
case *expvar.Float:
evar.F = value
case *expvar.Map:
evar.M = value
default:
// Unsupported type, ignore this variable.
ignored = append(ignored, evar.Name)
return
}
infoMu.Lock()
evar.Desc = descriptions[kv.Key]
evar.LabelName = mapLabelNames[kv.Key]
infoMu.Unlock()
// OM maps need a label name, while expvar ones do not. If we weren't
// told what to use, use a generic "key".
if evar.LabelName == "" {
evar.LabelName = "key"
}
vars = append(vars, evar)
})
// Sort the variables for reproducibility and readability.
sort.Slice(vars, func(i, j int) bool {
return vars[i].Name < vars[j].Name
})
for _, v := range vars {
writeVar(w, &v)
}
fmt.Fprintf(w, "# Generated by expvarom\n")
fmt.Fprintf(w, "# EXPERIMENTAL - Format is not fully standard yet\n")
fmt.Fprintf(w, "# Ignored variables: %q\n", ignored)
fmt.Fprintf(w, "# EOF\n") // Mandated by the standard.
}
func writeVar(w io.Writer, v *exportedVar) {
if v.Desc != "" {
fmt.Fprintf(w, "# HELP %s %s\n", v.Name, v.Desc)
}
if v.I != nil {
fmt.Fprintf(w, "%s %d\n\n", v.Name, v.I.Value())
return
}
if v.F != nil {
fmt.Fprintf(w, "%s %g\n\n", v.Name, v.F.Value())
return
}
if v.M != nil {
count := 0
v.M.Do(func(kv expvar.KeyValue) {
vs := ""
switch value := kv.Value.(type) {
case *expvar.Int:
vs = strconv.FormatInt(value.Value(), 10)
case *expvar.Float:
vs = strconv.FormatFloat(value.Value(), 'g', -1, 64)
default:
// We only support Int and Float in maps.
return
}
labelValue := quoteLabelValue(kv.Key)
fmt.Fprintf(w, "%s{%s=%s} %s\n",
v.Name, v.LabelName, labelValue, vs)
count++
})
if count > 0 {
fmt.Fprintf(w, "\n")
}
}
}
// metricNameToOM converts an expvar metric name into an OpenMetrics-compliant
// metric name. The latter is more restrictive, as it must match the regexp
// "[a-zA-Z_:][a-zA-Z0-9_:]*", AND the ':' is not allowed for a direct
// exporter.
//
// https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels
func metricNameToOM(name string) string {
n := ""
for _, c := range name {
if (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '_' {
n += string(c)
} else {
n += "_"
}
}
// If it begins with a number, prepend 'i' as a compromise.
if len(n) > 0 && n[0] >= '0' && n[0] <= '9' {
n = "i" + n
}
return n
}
// According to the spec, we only need to replace these 3 characters in label
// values.
var labelValueReplacer = strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
"\n", `\n`)
// quoteLabelValue takes an arbitrary string, and quotes it so it can be
// used as a label value. Output includes the wrapping `"`.
func quoteLabelValue(v string) string {
// The spec requires label values to be valid UTF8, with `\`, `"` and "\n"
// escaped. If it's invalid UTF8, hard-quote it first. This will result
// in uglier looking values, but they will be well formed.
if !utf8.ValidString(v) {
v = strconv.QuoteToASCII(v)
v = v[1 : len(v)-1]
}
return `"` + labelValueReplacer.Replace(v) + `"`
}
// NewInt registers a new expvar.Int variable, with the given description.
func NewInt(name, desc string) *expvar.Int {
infoMu.Lock()
descriptions[name] = desc
infoMu.Unlock()
return expvar.NewInt(name)
}
// NewFloat registers a new expvar.Float variable, with the given description.
func NewFloat(name, desc string) *expvar.Float {
infoMu.Lock()
descriptions[name] = desc
infoMu.Unlock()
return expvar.NewFloat(name)
}
// NewMap registers a new expvar.Map variable, with the given label
// name and description.
func NewMap(name, labelName, desc string) *expvar.Map {
// Prevent accidents when using the description as the label name.
if strings.Contains(labelName, " ") {
panic(fmt.Sprintf(
"label name has spaces, mix up with the description? %q",
labelName))
}
infoMu.Lock()
descriptions[name] = desc
mapLabelNames[name] = labelName
infoMu.Unlock()
return expvar.NewMap(name)
}