Skip to content

Commit 3fd1c2c

Browse files
authored
Introduce histogram support (prometheus-community#435)
* Introduce histogram support Prior to this change, the custom queries were restricted to counters and gauges. This change introduces a new ColumnUsage, namely HISTOGRAM, that expects the column to contain an array of upper inclusive bounds for each observation bucket in the emitted metric. It also expects three more columns to be present with the suffixes: - `_bucket`, containing an array of cumulative counters for the observation buckets; - `_sum`, the total sum of all observed values; and - `_count`, the count of events that have been observed. A flag has been added to the MetricMap struct to easily identify metrics that should emit a histogram and the construction of a histogram metric is aided by the pg.Array function and a new helper dbToUint64 function. Finally, and example of usage is given in queries.yaml. fixes prometheus-community#402 Signed-off-by: Corin Lawson <[email protected]> * Introduces tests for histogram support Prior to this change, the histogram support was untested. This change introduces a new integration test that reads a user query containing a number of histogram metrics. Also, additional checks have been added to TestBooleanConversionToValueAndString to test dbToUint64. Signed-off-by: Corin Lawson <[email protected]>
1 parent 3864bbc commit 3fd1c2c

File tree

5 files changed

+317
-7
lines changed

5 files changed

+317
-7
lines changed

cmd/postgres_exporter/postgres_exporter.go

+127-6
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ const (
8080
GAUGE ColumnUsage = iota // Use this column as a gauge
8181
MAPPEDMETRIC ColumnUsage = iota // Use this column with the supplied mapping of text values
8282
DURATION ColumnUsage = iota // This column should be interpreted as a text duration (and converted to milliseconds)
83+
HISTOGRAM ColumnUsage = iota // Use this column as a histogram
8384
)
8485

8586
// UnmarshalYAML implements the yaml.Unmarshaller interface.
@@ -169,6 +170,7 @@ type MetricMapNamespace struct {
169170
// be mapped to by the collector
170171
type MetricMap struct {
171172
discard bool // Should metric be discarded during mapping?
173+
histogram bool // Should metric be treated as a histogram?
172174
vtype prometheus.ValueType // Prometheus valuetype
173175
desc *prometheus.Desc // Prometheus descriptor
174176
conversion func(interface{}) (float64, bool) // Conversion function to turn PG result into float64
@@ -650,6 +652,27 @@ func makeDescMap(pgVersion semver.Version, serverLabels prometheus.Labels, metri
650652
return dbToFloat64(in)
651653
},
652654
}
655+
case HISTOGRAM:
656+
thisMap[columnName] = MetricMap{
657+
histogram: true,
658+
vtype: prometheus.UntypedValue,
659+
desc: prometheus.NewDesc(fmt.Sprintf("%s_%s", namespace, columnName), columnMapping.description, variableLabels, serverLabels),
660+
conversion: func(in interface{}) (float64, bool) {
661+
return dbToFloat64(in)
662+
},
663+
}
664+
thisMap[columnName+"_bucket"] = MetricMap{
665+
histogram: true,
666+
discard: true,
667+
}
668+
thisMap[columnName+"_sum"] = MetricMap{
669+
histogram: true,
670+
discard: true,
671+
}
672+
thisMap[columnName+"_count"] = MetricMap{
673+
histogram: true,
674+
discard: true,
675+
}
653676
case MAPPEDMETRIC:
654677
thisMap[columnName] = MetricMap{
655678
vtype: prometheus.GaugeValue,
@@ -721,6 +744,9 @@ func stringToColumnUsage(s string) (ColumnUsage, error) {
721744
case "GAUGE":
722745
u = GAUGE
723746

747+
case "HISTOGRAM":
748+
u = HISTOGRAM
749+
724750
case "MAPPEDMETRIC":
725751
u = MAPPEDMETRIC
726752

@@ -772,6 +798,46 @@ func dbToFloat64(t interface{}) (float64, bool) {
772798
}
773799
}
774800

801+
// Convert database.sql types to uint64 for Prometheus consumption. Null types are mapped to 0. string and []byte
802+
// types are mapped as 0 and !ok
803+
func dbToUint64(t interface{}) (uint64, bool) {
804+
switch v := t.(type) {
805+
case uint64:
806+
return v, true
807+
case int64:
808+
return uint64(v), true
809+
case float64:
810+
return uint64(v), true
811+
case time.Time:
812+
return uint64(v.Unix()), true
813+
case []byte:
814+
// Try and convert to string and then parse to a uint64
815+
strV := string(v)
816+
result, err := strconv.ParseUint(strV, 10, 64)
817+
if err != nil {
818+
log.Infoln("Could not parse []byte:", err)
819+
return 0, false
820+
}
821+
return result, true
822+
case string:
823+
result, err := strconv.ParseUint(v, 10, 64)
824+
if err != nil {
825+
log.Infoln("Could not parse string:", err)
826+
return 0, false
827+
}
828+
return result, true
829+
case bool:
830+
if v {
831+
return 1, true
832+
}
833+
return 0, true
834+
case nil:
835+
return 0, true
836+
default:
837+
return 0, false
838+
}
839+
}
840+
775841
// Convert database.sql to string for Prometheus labels. Null types are mapped to empty strings.
776842
func dbToString(t interface{}) (string, bool) {
777843
switch v := t.(type) {
@@ -1304,13 +1370,68 @@ func queryNamespaceMapping(server *Server, namespace string, mapping MetricMapNa
13041370
continue
13051371
}
13061372

1307-
value, ok := dbToFloat64(columnData[idx])
1308-
if !ok {
1309-
nonfatalErrors = append(nonfatalErrors, errors.New(fmt.Sprintln("Unexpected error parsing column: ", namespace, columnName, columnData[idx])))
1310-
continue
1373+
if metricMapping.histogram {
1374+
var keys []float64
1375+
err = pq.Array(&keys).Scan(columnData[idx])
1376+
if err != nil {
1377+
return []prometheus.Metric{}, []error{}, errors.New(fmt.Sprintln("Error retrieving", columnName, "buckets:", namespace, err))
1378+
}
1379+
1380+
var values []int64
1381+
valuesIdx, ok := columnIdx[columnName+"_bucket"]
1382+
if !ok {
1383+
nonfatalErrors = append(nonfatalErrors, errors.New(fmt.Sprintln("Missing column: ", namespace, columnName+"_bucket")))
1384+
continue
1385+
}
1386+
err = pq.Array(&values).Scan(columnData[valuesIdx])
1387+
if err != nil {
1388+
return []prometheus.Metric{}, []error{}, errors.New(fmt.Sprintln("Error retrieving", columnName, "bucket values:", namespace, err))
1389+
}
1390+
1391+
buckets := make(map[float64]uint64, len(keys))
1392+
for i, key := range keys {
1393+
if i >= len(values) {
1394+
break
1395+
}
1396+
buckets[key] = uint64(values[i])
1397+
}
1398+
1399+
idx, ok = columnIdx[columnName+"_sum"]
1400+
if !ok {
1401+
nonfatalErrors = append(nonfatalErrors, errors.New(fmt.Sprintln("Missing column: ", namespace, columnName+"_sum")))
1402+
continue
1403+
}
1404+
sum, ok := dbToFloat64(columnData[idx])
1405+
if !ok {
1406+
nonfatalErrors = append(nonfatalErrors, errors.New(fmt.Sprintln("Unexpected error parsing column: ", namespace, columnName+"_sum", columnData[idx])))
1407+
continue
1408+
}
1409+
1410+
idx, ok = columnIdx[columnName+"_count"]
1411+
if !ok {
1412+
nonfatalErrors = append(nonfatalErrors, errors.New(fmt.Sprintln("Missing column: ", namespace, columnName+"_count")))
1413+
continue
1414+
}
1415+
count, ok := dbToUint64(columnData[idx])
1416+
if !ok {
1417+
nonfatalErrors = append(nonfatalErrors, errors.New(fmt.Sprintln("Unexpected error parsing column: ", namespace, columnName+"_count", columnData[idx])))
1418+
continue
1419+
}
1420+
1421+
metric = prometheus.MustNewConstHistogram(
1422+
metricMapping.desc,
1423+
count, sum, buckets,
1424+
labels...,
1425+
)
1426+
} else {
1427+
value, ok := dbToFloat64(columnData[idx])
1428+
if !ok {
1429+
nonfatalErrors = append(nonfatalErrors, errors.New(fmt.Sprintln("Unexpected error parsing column: ", namespace, columnName, columnData[idx])))
1430+
continue
1431+
}
1432+
// Generate the metric
1433+
metric = prometheus.MustNewConstMetric(metricMapping.desc, metricMapping.vtype, value, labels...)
13111434
}
1312-
// Generate the metric
1313-
metric = prometheus.MustNewConstMetric(metricMapping.desc, metricMapping.vtype, value, labels...)
13141435
} else {
13151436
// Unknown metric. Report as untyped if scan to float64 works, else note an error too.
13161437
metricLabel := fmt.Sprintf("%s_%s", namespace, columnName)

cmd/postgres_exporter/postgres_exporter_integration_test.go

+23
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,26 @@ func (s *IntegrationSuite) TestUnknownMetricParsingDoesntCrash(c *C) {
126126
// scrape the exporter and make sure it works
127127
exporter.scrape(ch)
128128
}
129+
130+
// TestExtendQueriesDoesntCrash tests that specifying extend.query-path doesn't
131+
// crash.
132+
func (s *IntegrationSuite) TestExtendQueriesDoesntCrash(c *C) {
133+
// Setup a dummy channel to consume metrics
134+
ch := make(chan prometheus.Metric, 100)
135+
go func() {
136+
for range ch {
137+
}
138+
}()
139+
140+
dsn := os.Getenv("DATA_SOURCE_NAME")
141+
c.Assert(dsn, Not(Equals), "")
142+
143+
exporter := NewExporter(
144+
strings.Split(dsn, ","),
145+
WithUserQueriesPath("../user_queries_test.yaml"),
146+
)
147+
c.Assert(exporter, NotNil)
148+
149+
// scrape the exporter and make sure it works
150+
exporter.scrape(ch)
151+
}

cmd/postgres_exporter/postgres_exporter_test.go

+72-1
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ package main
44

55
import (
66
"io/ioutil"
7+
"math"
78
"os"
89
"reflect"
910
"testing"
11+
"time"
1012

1113
"github.com/blang/semver"
1214
"github.com/prometheus/client_golang/prometheus"
@@ -287,13 +289,30 @@ func UnsetEnvironment(c *C, d string) {
287289
c.Assert(err, IsNil)
288290
}
289291

292+
type isNaNChecker struct {
293+
*CheckerInfo
294+
}
295+
296+
var IsNaN Checker = &isNaNChecker{
297+
&CheckerInfo{Name: "IsNaN", Params: []string{"value"}},
298+
}
299+
300+
func (checker *isNaNChecker) Check(params []interface{}, names []string) (result bool, error string) {
301+
param, ok := (params[0]).(float64)
302+
if !ok {
303+
return false, "obtained value type is not a float"
304+
}
305+
return math.IsNaN(param), ""
306+
}
307+
290308
// test boolean metric type gets converted to float
291309
func (s *FunctionalSuite) TestBooleanConversionToValueAndString(c *C) {
292310

293311
type TestCase struct {
294312
input interface{}
295313
expectedString string
296314
expectedValue float64
315+
expectedCount uint64
297316
expectedOK bool
298317
}
299318

@@ -302,19 +321,71 @@ func (s *FunctionalSuite) TestBooleanConversionToValueAndString(c *C) {
302321
input: true,
303322
expectedString: "true",
304323
expectedValue: 1.0,
324+
expectedCount: 1,
305325
expectedOK: true,
306326
},
307327
{
308328
input: false,
309329
expectedString: "false",
310330
expectedValue: 0.0,
331+
expectedCount: 0,
332+
expectedOK: true,
333+
},
334+
{
335+
input: nil,
336+
expectedString: "",
337+
expectedValue: math.NaN(),
338+
expectedCount: 0,
339+
expectedOK: true,
340+
},
341+
{
342+
input: TestCase{},
343+
expectedString: "",
344+
expectedValue: math.NaN(),
345+
expectedCount: 0,
346+
expectedOK: false,
347+
},
348+
{
349+
input: 123.0,
350+
expectedString: "123",
351+
expectedValue: 123.0,
352+
expectedCount: 123,
353+
expectedOK: true,
354+
},
355+
{
356+
input: "123",
357+
expectedString: "123",
358+
expectedValue: 123.0,
359+
expectedCount: 123,
360+
expectedOK: true,
361+
},
362+
{
363+
input: []byte("123"),
364+
expectedString: "123",
365+
expectedValue: 123.0,
366+
expectedCount: 123,
367+
expectedOK: true,
368+
},
369+
{
370+
input: time.Unix(1600000000, 0),
371+
expectedString: "1600000000",
372+
expectedValue: 1600000000.0,
373+
expectedCount: 1600000000,
311374
expectedOK: true,
312375
},
313376
}
314377

315378
for _, cs := range cases {
316379
value, ok := dbToFloat64(cs.input)
317-
c.Assert(value, Equals, cs.expectedValue)
380+
if math.IsNaN(cs.expectedValue) {
381+
c.Assert(value, IsNaN)
382+
} else {
383+
c.Assert(value, Equals, cs.expectedValue)
384+
}
385+
c.Assert(ok, Equals, cs.expectedOK)
386+
387+
count, ok := dbToUint64(cs.input)
388+
c.Assert(count, Equals, cs.expectedCount)
318389
c.Assert(ok, Equals, cs.expectedOK)
319390

320391
str, ok := dbToString(cs.input)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
random:
2+
query: |
3+
WITH data AS (SELECT floor(random()*10) AS d FROM generate_series(1,100)),
4+
metrics AS (SELECT SUM(d) AS sum, COUNT(*) AS count FROM data),
5+
buckets AS (SELECT le, SUM(CASE WHEN d <= le THEN 1 ELSE 0 END) AS d
6+
FROM data, UNNEST(ARRAY[1, 2, 4, 8]) AS le GROUP BY le)
7+
SELECT
8+
sum AS histogram_sum,
9+
count AS histogram_count,
10+
ARRAY_AGG(le) AS histogram,
11+
ARRAY_AGG(d) AS histogram_bucket,
12+
ARRAY_AGG(le) AS missing,
13+
ARRAY_AGG(le) AS missing_sum,
14+
ARRAY_AGG(d) AS missing_sum_bucket,
15+
ARRAY_AGG(le) AS missing_count,
16+
ARRAY_AGG(d) AS missing_count_bucket,
17+
sum AS missing_count_sum,
18+
ARRAY_AGG(le) AS unexpected_sum,
19+
ARRAY_AGG(d) AS unexpected_sum_bucket,
20+
'data' AS unexpected_sum_sum,
21+
ARRAY_AGG(le) AS unexpected_count,
22+
ARRAY_AGG(d) AS unexpected_count_bucket,
23+
sum AS unexpected_count_sum,
24+
'nan'::varchar AS unexpected_count_count,
25+
ARRAY_AGG(le) AS unexpected_bytes,
26+
ARRAY_AGG(d) AS unexpected_bytes_bucket,
27+
sum AS unexpected_bytes_sum,
28+
'nan'::bytea AS unexpected_bytes_count
29+
FROM metrics, buckets GROUP BY 1,2
30+
metrics:
31+
- histogram:
32+
usage: "HISTOGRAM"
33+
description: "Random data"
34+
- missing:
35+
usage: "HISTOGRAM"
36+
description: "nonfatal error"
37+
- missing_sum:
38+
usage: "HISTOGRAM"
39+
description: "nonfatal error"
40+
- missing_count:
41+
usage: "HISTOGRAM"
42+
description: "nonfatal error"
43+
- unexpected_sum:
44+
usage: "HISTOGRAM"
45+
description: "nonfatal error"
46+
- unexpected_count:
47+
usage: "HISTOGRAM"
48+
description: "nonfatal error"
49+
- unexpected_bytes:
50+
usage: "HISTOGRAM"
51+
description: "nonfatal error"

0 commit comments

Comments
 (0)