// 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) }