Files
gvisor/pkg/prometheus/prometheus_test.go
Etienne Perot d3b95fae44 Deflake prometheus_test:TestWriteMultipleSnapshots.
The Prometheus parser doesn't guarantee that it returns metric data in
timestamp-sorted order. So sort it in the test manually.

Before: Fails 7 out of 2048 times
After: Fails 0 out of 2048 times
PiperOrigin-RevId: 688224325
2024-10-21 12:10:27 -07:00

1958 lines
64 KiB
Go

// Copyright 2022 The gVisor Authors.
//
// 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 prometheus
import (
"bytes"
"errors"
"fmt"
"io"
"math"
"sort"
"strings"
"sync"
"testing"
"time"
"unicode"
v1proto "github.com/golang/protobuf/proto"
"github.com/google/go-cmp/cmp"
"github.com/prometheus/common/expfmt"
"google.golang.org/protobuf/encoding/prototext"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/testing/protocmp"
pb "gvisor.dev/gvisor/pkg/metric/metric_go_proto"
)
// timeNowMu is used to synchronize injection of time.Now.
var timeNowMu sync.Mutex
// at executes a function with the clock returning a given time.
func at(when time.Time, f func()) {
timeNowMu.Lock()
defer timeNowMu.Unlock()
previousFunc := timeNow
timeNow = func() time.Time { return when }
defer func() { timeNow = previousFunc }()
f()
}
// newSnapshotAt creates a new Snapshot with the given timestamp.
func newSnapshotAt(when time.Time) *Snapshot {
var s *Snapshot
at(when, func() {
s = NewSnapshot()
})
return s
}
// Helper builder type for metric metadata.
type metricMetadata struct {
PB *pb.MetricMetadata
Fields map[string]string
}
func (m *metricMetadata) clone() *metricMetadata {
m2 := &metricMetadata{
PB: &pb.MetricMetadata{},
Fields: make(map[string]string, len(m.Fields)),
}
proto.Merge(m2.PB, m.PB)
for k, v := range m.Fields {
m2.Fields[k] = v
}
return m2
}
// withField returns a copy of this *metricMetadata with the given field added
// to its metadata.
func (m *metricMetadata) withField(fields ...*pb.MetricMetadata_Field) *metricMetadata {
m2 := m.clone()
m2.PB.Fields = make([]*pb.MetricMetadata_Field, 0, len(m.Fields)+len(fields))
copy(m2.PB.Fields, m.PB.Fields)
m2.PB.Fields = append(m2.PB.Fields, fields...)
return m2
}
// metric returns the Metric metadata struct for this metric metadata.
func (m *metricMetadata) metric() *Metric {
var metricType Type
switch m.PB.GetType() {
case pb.MetricMetadata_TYPE_UINT64:
if m.PB.GetCumulative() {
metricType = TypeCounter
} else {
metricType = TypeGauge
}
case pb.MetricMetadata_TYPE_DISTRIBUTION:
metricType = TypeHistogram
default:
panic(fmt.Sprintf("invalid type %v", m.PB.GetType()))
}
return &Metric{
Name: m.PB.GetPrometheusName(),
Type: metricType,
Help: m.PB.GetDescription(),
}
}
// Convenient metric field metadata definitions.
var (
field1 = &pb.MetricMetadata_Field{
FieldName: "field1",
AllowedValues: []string{"val1a", "val1b"},
}
field2 = &pb.MetricMetadata_Field{
FieldName: "field2",
AllowedValues: []string{"val2a", "val2b"},
}
)
// fieldVal returns a copy of this *metricMetadata with the given field-value
// stored on the side of the metadata. Meant to be used during snapshot data
// construction, where methods like int() make it easy to construct *Data
// structs with field values.
func (m *metricMetadata) fieldVal(field *pb.MetricMetadata_Field, val string) *metricMetadata {
return m.fieldVals(map[*pb.MetricMetadata_Field]string{field: val})
}
// fieldVals acts like fieldVal but for multiple fields, at the expense of
// having a less convenient function signature.
func (m *metricMetadata) fieldVals(fieldToVal map[*pb.MetricMetadata_Field]string) *metricMetadata {
m2 := m.clone()
for field, val := range fieldToVal {
m2.Fields[field.GetFieldName()] = val
}
return m2
}
// labels returns a label key-value map associated with the metricMetadata.
func (m *metricMetadata) labels() map[string]string {
if len(m.Fields) == 0 {
return nil
}
return m.Fields
}
// int returns a new Data struct with the given value for the current metric.
// If the current metric has fields, all of its fields must accept exactly one
// value, and this value will be used as the value for that field.
// If a field accepts multiple values, the function will panic.
func (m *metricMetadata) int(val int64) *Data {
data := NewIntData(m.metric(), val)
data.Labels = m.labels()
return data
}
// float returns a new Data struct with the given value for the current metric.
// If the current metric has fields, all of its fields must accept exactly one
// value, and this value will be used as the value for that field.
// If a field accepts multiple values, the function will panic.
func (m *metricMetadata) float(val float64) *Data {
data := NewFloatData(m.metric(), val)
data.Labels = m.labels()
return data
}
// float returns a new Data struct with the given value for the current metric.
// If the current metric has fields, all of its fields must accept exactly one
// value, and this value will be used as the value for that field.
// If a field accepts multiple values, the function will panic.
func (m *metricMetadata) dist(samples ...int64) *Data {
var total, min, max int64
var ssd float64
buckets := make([]Bucket, len(m.PB.GetDistributionBucketLowerBounds())+1)
var bucket *Bucket
for i, lowerBound := range m.PB.GetDistributionBucketLowerBounds() {
(&buckets[i]).UpperBound = Number{Int: lowerBound}
}
(&buckets[len(buckets)-1]).UpperBound = Number{Float: math.Inf(1)}
for i, sample := range samples {
if i == 0 {
min = sample
max = sample
} else {
if sample < min {
min = sample
}
if sample > max {
max = sample
}
oldMean := float64(total) / float64(i+1)
newMean := float64(total+sample) / float64(i+2)
ssd += (float64(sample) - oldMean) * (float64(sample) - newMean)
}
total += sample
bucket = &buckets[0]
for i, lowerBound := range m.PB.GetDistributionBucketLowerBounds() {
if sample >= lowerBound {
bucket = &buckets[i+1]
} else {
break
}
}
bucket.Samples++
}
return &Data{
Metric: m.metric(),
Labels: m.labels(),
HistogramValue: &Histogram{
Total: Number{Int: total},
Buckets: buckets,
Min: Number{Int: min},
Max: Number{Int: max},
SumOfSquaredDeviations: Number{Float: ssd},
},
}
}
// Convenient metric metadata definitions.
var (
fooInt = &metricMetadata{
PB: &pb.MetricMetadata{
Name: "fooInt",
PrometheusName: "foo_int",
Description: "An integer about foo",
Cumulative: false,
Units: pb.MetricMetadata_UNITS_NONE,
Sync: true,
Type: pb.MetricMetadata_TYPE_UINT64,
},
}
fooCounter = &metricMetadata{
PB: &pb.MetricMetadata{
Name: "fooCounter",
PrometheusName: "foo_counter",
Description: "A counter of foos",
Cumulative: true,
Units: pb.MetricMetadata_UNITS_NONE,
Sync: true,
Type: pb.MetricMetadata_TYPE_UINT64,
},
}
fooDist = &metricMetadata{
PB: &pb.MetricMetadata{
Name: "fooDist",
PrometheusName: "foo_dist",
Description: "A distribution about foo",
Cumulative: false,
Units: pb.MetricMetadata_UNITS_NONE,
Sync: true,
Type: pb.MetricMetadata_TYPE_DISTRIBUTION,
DistributionBucketLowerBounds: []int64{0, 1, 2, 4, 8},
},
}
)
// newMetricRegistration returns a new *metricRegistration.
func newMetricRegistration(metricMetadata ...*metricMetadata) *pb.MetricRegistration {
metadatas := make([]*pb.MetricMetadata, len(metricMetadata))
for i, mm := range metricMetadata {
metadatas[i] = mm.PB
}
return &pb.MetricRegistration{
Metrics: metadatas,
}
}
func TestVerifier(t *testing.T) {
testStart := time.Now()
epsilon := func(n int) time.Time {
return testStart.Add(time.Duration(n) * time.Millisecond)
}
for _, test := range []struct {
Name string
// At is the time at which the test executes.
// If unset, `testStart` is assumed.
At time.Time
// Registration is the metric registration data.
Registration *pb.MetricRegistration
// WantVerifierCreationErr is true if the test expects the
// creation of the Verifier to fail. All the fields below it
// are ignored in this case.
WantVerifierCreationErr bool
// WantSuccess is a sequence of Snapshots to present to
// the verifier. The test expects all of them to pass verification.
// If unset, the test simply presents the WantFail Snapshot.
// If both WantSuccess and WantFail are unset, the test presents
// an empty snapshot and expects it to succeed.
WantSuccess []*Snapshot
// WantFail is a Snapshot to present to the verifier after all
// snapshots in WantSuccess have been presented.
// The test expects this Snapshot to fail verification.
// If unset, the test does not present any snapshot after
// having presented the WantSuccess Snapshots.
WantFail *Snapshot
}{
{
Name: "no metrics, empty snapshot",
},
{
Name: "duplicate metric",
Registration: newMetricRegistration(fooInt, fooInt),
WantVerifierCreationErr: true,
},
{
Name: "duplicate metric with different field set",
Registration: newMetricRegistration(fooInt, fooInt.withField(field1)),
WantVerifierCreationErr: true,
},
{
Name: "duplicate field in metric",
Registration: newMetricRegistration(fooInt.withField(field1, field1)),
WantVerifierCreationErr: true,
},
{
Name: "no field allowed value",
Registration: newMetricRegistration(fooInt.withField(&pb.MetricMetadata_Field{
FieldName: "field1",
})),
WantVerifierCreationErr: true,
},
{
Name: "duplicate field allowed value",
Registration: newMetricRegistration(fooInt.withField(&pb.MetricMetadata_Field{
FieldName: "field1",
AllowedValues: []string{"val1", "val1"},
})),
WantVerifierCreationErr: true,
},
{
Name: "invalid metric type",
Registration: newMetricRegistration(&metricMetadata{
PB: &pb.MetricMetadata{
Name: "fooBar",
PrometheusName: "foo_bar",
Type: pb.MetricMetadata_Type(1337),
}},
),
WantVerifierCreationErr: true,
},
{
Name: "empty metric name",
Registration: newMetricRegistration(&metricMetadata{
PB: &pb.MetricMetadata{
PrometheusName: "foo_bar",
Type: pb.MetricMetadata_TYPE_UINT64,
}},
),
WantVerifierCreationErr: true,
},
{
Name: "empty Prometheus metric name",
Registration: newMetricRegistration(&metricMetadata{
PB: &pb.MetricMetadata{
Name: "fooBar",
Type: pb.MetricMetadata_TYPE_UINT64,
}},
),
WantVerifierCreationErr: true,
},
{
Name: "bad Prometheus metric name",
Registration: newMetricRegistration(&metricMetadata{
PB: &pb.MetricMetadata{
Name: "fooBar",
PrometheusName: "fooBar",
Type: pb.MetricMetadata_TYPE_UINT64,
}},
),
WantVerifierCreationErr: true,
},
{
Name: "bad first Prometheus metric name character",
Registration: newMetricRegistration(&metricMetadata{
PB: &pb.MetricMetadata{
Name: "fooBar",
PrometheusName: "_foo_bar",
Type: pb.MetricMetadata_TYPE_UINT64,
}},
),
WantVerifierCreationErr: true,
},
{
Name: "Prometheus metric name starts with reserved prefix",
Registration: newMetricRegistration(&metricMetadata{
PB: &pb.MetricMetadata{
Name: "metaFooBar",
PrometheusName: "meta_foo_bar",
Type: pb.MetricMetadata_TYPE_UINT64,
}},
),
WantVerifierCreationErr: true,
},
{
Name: "Prometheus metric name does not starts with reserved prefix but non-Prometheus metric name does",
Registration: newMetricRegistration(&metricMetadata{
PB: &pb.MetricMetadata{
Name: "metaFooBar",
PrometheusName: "not_meta_foo_bar",
Type: pb.MetricMetadata_TYPE_UINT64,
}},
),
WantVerifierCreationErr: false,
},
{
Name: "Prometheus metric name matches reserved one",
Registration: newMetricRegistration(&metricMetadata{
PB: &pb.MetricMetadata{
Name: "doesNotMatter",
PrometheusName: ProcessStartTimeSeconds.Name,
Type: pb.MetricMetadata_TYPE_UINT64,
}},
),
WantVerifierCreationErr: true,
},
{
Name: "no buckets",
Registration: newMetricRegistration(&metricMetadata{
PB: &pb.MetricMetadata{
Name: "fooBar",
PrometheusName: "foo_bar",
Type: pb.MetricMetadata_TYPE_DISTRIBUTION,
DistributionBucketLowerBounds: []int64{},
}},
),
WantVerifierCreationErr: true,
},
{
Name: "too many buckets",
Registration: newMetricRegistration(&metricMetadata{
PB: &pb.MetricMetadata{
Name: "fooBar",
PrometheusName: "foo_bar",
Type: pb.MetricMetadata_TYPE_DISTRIBUTION,
DistributionBucketLowerBounds: make([]int64, 999),
}},
),
WantVerifierCreationErr: true,
},
{
Name: "successful registration of complex set of metrics",
Registration: newMetricRegistration(
fooInt,
fooCounter.withField(field1, field2),
fooDist.withField(field2),
),
},
{
Name: "snapshot time ordering",
At: epsilon(0),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-3)),
newSnapshotAt(epsilon(-2)),
newSnapshotAt(epsilon(-1)),
},
WantFail: newSnapshotAt(epsilon(-2)),
},
{
Name: "same snapshot time is ok",
At: epsilon(0),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-3)),
newSnapshotAt(epsilon(-2)),
newSnapshotAt(epsilon(-1)),
newSnapshotAt(epsilon(-1)),
newSnapshotAt(epsilon(-1)),
newSnapshotAt(epsilon(-1)),
newSnapshotAt(epsilon(0)),
newSnapshotAt(epsilon(0)),
newSnapshotAt(epsilon(0)),
newSnapshotAt(epsilon(0)),
},
},
{
Name: "snapshot from the future",
At: epsilon(0),
WantFail: newSnapshotAt(epsilon(1)),
},
{
Name: "snapshot from the long past",
At: testStart,
WantFail: newSnapshotAt(testStart.Add(-25 * time.Hour)),
},
{
Name: "simple metric update",
Registration: newMetricRegistration(fooInt),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-1)).Add(
fooInt.int(2),
),
},
},
{
Name: "simple metric update multiple times",
Registration: newMetricRegistration(fooInt),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-3)).Add(fooInt.int(2)),
newSnapshotAt(epsilon(-2)).Add(fooInt.int(-1)),
newSnapshotAt(epsilon(-1)).Add(fooInt.int(4)),
},
},
{
Name: "counter can go forwards",
Registration: newMetricRegistration(fooCounter),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-3)).Add(fooCounter.int(1)),
newSnapshotAt(epsilon(-2)).Add(fooCounter.int(3)),
newSnapshotAt(epsilon(-1)).Add(fooCounter.int(3)),
},
},
{
Name: "counter cannot go backwards",
Registration: newMetricRegistration(fooCounter),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-3)).Add(fooCounter.int(1)),
newSnapshotAt(epsilon(-2)).Add(fooCounter.int(3)),
},
WantFail: newSnapshotAt(epsilon(-1)).Add(fooCounter.int(2)),
},
{
Name: "counter cannot change type",
Registration: newMetricRegistration(fooCounter),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-3)).Add(fooCounter.int(1)),
newSnapshotAt(epsilon(-2)).Add(fooCounter.int(3)),
},
WantFail: newSnapshotAt(epsilon(-1)).Add(fooCounter.float(4)),
},
{
Name: "update for unknown metric",
Registration: newMetricRegistration(fooInt),
WantFail: newSnapshotAt(epsilon(-1)).Add(fooCounter.int(2)),
},
{
Name: "update for mismatching metric definition: type",
Registration: newMetricRegistration(fooInt),
WantFail: newSnapshotAt(epsilon(-1)).Add(
(&metricMetadata{PB: &pb.MetricMetadata{
PrometheusName: fooInt.PB.GetPrometheusName(),
Type: pb.MetricMetadata_TYPE_DISTRIBUTION,
Description: fooInt.PB.GetDescription(),
}}).int(2),
),
},
{
Name: "update for mismatching metric definition: name",
Registration: newMetricRegistration(fooInt),
WantFail: newSnapshotAt(epsilon(-1)).Add(
(&metricMetadata{PB: &pb.MetricMetadata{
PrometheusName: "not_foo_int",
Type: fooInt.PB.GetType(),
Description: fooInt.PB.GetDescription(),
}}).int(2),
),
},
{
Name: "update for mismatching metric definition: description",
Registration: newMetricRegistration(fooInt),
WantFail: newSnapshotAt(epsilon(-1)).Add(
(&metricMetadata{PB: &pb.MetricMetadata{
PrometheusName: fooInt.PB.GetPrometheusName(),
Type: fooInt.PB.GetType(),
Description: "not fooInt's description",
}}).int(2),
),
},
{
Name: "update with no fields for metric with fields",
Registration: newMetricRegistration(fooInt.withField(field1)),
WantFail: newSnapshotAt(epsilon(-1)).Add(fooInt.int(2)),
},
{
Name: "update with fields for metric without fields",
Registration: newMetricRegistration(fooInt),
WantFail: newSnapshotAt(epsilon(-1)).Add(
fooInt.fieldVal(field1, "val1a").int(2),
),
},
{
Name: "update with invalid field value",
Registration: newMetricRegistration(fooInt.withField(field1)),
WantFail: newSnapshotAt(epsilon(-1)).Add(
fooInt.fieldVal(field1, "not_val1a").int(2),
),
},
{
Name: "update with valid field value for wrong field",
Registration: newMetricRegistration(fooInt.withField(field1)),
WantFail: newSnapshotAt(epsilon(-1)).Add(
fooInt.fieldVal(field2, "val1a").int(2),
),
},
{
Name: "update with valid field values provided twice",
Registration: newMetricRegistration(fooInt.withField(field1)),
WantFail: newSnapshotAt(epsilon(-1)).Add(
fooInt.fieldVal(field1, "val1a").int(2),
fooInt.fieldVal(field1, "val1a").int(2),
),
},
{
Name: "update with valid field value",
Registration: newMetricRegistration(fooInt.withField(field1)),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-1)).Add(
fooInt.fieldVal(field1, "val1a").int(7),
fooInt.fieldVal(field1, "val1b").int(2),
),
},
},
{
Name: "update with multiple valid field value",
Registration: newMetricRegistration(fooCounter.withField(field1, field2)),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-1)).Add(
fooCounter.fieldVals(map[*pb.MetricMetadata_Field]string{
field1: "val1a",
field2: "val2a",
}).int(3),
fooCounter.fieldVals(map[*pb.MetricMetadata_Field]string{
field1: "val1b",
field2: "val2a",
}).int(2),
fooCounter.fieldVals(map[*pb.MetricMetadata_Field]string{
field1: "val1a",
field2: "val2b",
}).int(1),
fooCounter.fieldVals(map[*pb.MetricMetadata_Field]string{
field1: "val1b",
field2: "val2b",
}).int(4),
),
},
},
{
Name: "update with multiple valid field values but duplicated",
Registration: newMetricRegistration(fooCounter.withField(field1, field2)),
WantFail: newSnapshotAt(epsilon(-1)).Add(
fooCounter.fieldVals(map[*pb.MetricMetadata_Field]string{
field1: "val1b",
field2: "val2b",
}).int(4),
fooCounter.fieldVals(map[*pb.MetricMetadata_Field]string{
field1: "val1b",
field2: "val2b",
}).int(4),
),
},
{
Name: "update with same valid field values across two metrics",
Registration: newMetricRegistration(
fooInt.withField(field1, field2),
fooCounter.withField(field1, field2),
),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-1)).Add(
fooInt.fieldVals(map[*pb.MetricMetadata_Field]string{
field1: "val1a",
field2: "val2a",
}).int(3),
fooCounter.fieldVals(map[*pb.MetricMetadata_Field]string{
field1: "val1a",
field2: "val2a",
}).int(3),
),
},
},
{
Name: "update with multiple value types",
Registration: newMetricRegistration(fooInt),
WantFail: newSnapshotAt(epsilon(-1)).Add(
&Data{
Metric: fooInt.metric(),
Number: &Number{Int: 2},
HistogramValue: &Histogram{
Total: Number{Int: 5},
Buckets: []Bucket{
{UpperBound: Number{Int: 0}, Samples: 1},
{UpperBound: Number{Int: 1}, Samples: 1},
},
},
},
),
},
{
Name: "integer metric gets float value",
Registration: newMetricRegistration(fooInt),
WantFail: newSnapshotAt(epsilon(-1)).Add(fooInt.float(2.5)),
},
{
Name: "metric gets no value",
Registration: newMetricRegistration(fooInt),
WantFail: newSnapshotAt(epsilon(-1)).Add(&Data{Metric: fooInt.metric()}),
},
{
Name: "distribution gets integer value",
Registration: newMetricRegistration(fooDist),
WantFail: newSnapshotAt(epsilon(-1)).Add(
fooDist.int(2),
),
},
{
Name: "successful distribution",
Registration: newMetricRegistration(fooDist),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-1)).Add(
fooDist.dist(1, 2, 3, 4, 5, 6),
),
},
},
{
Name: "distribution updates",
Registration: newMetricRegistration(fooDist),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-2)).Add(
fooDist.dist(1, 2, 3, 4, 5, 6),
),
newSnapshotAt(epsilon(-1)).Add(
fooDist.dist(0, 1, 1, 2, 2, 3, 4, 5, 5, 6, 7, 8, 9, 25),
),
},
},
{
Name: "distribution updates with fields",
Registration: newMetricRegistration(fooDist.withField(field1)),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-2)).Add(
fooDist.fieldVal(field1, "val1a").dist(1, 2, 3, 4, 5, 6),
),
newSnapshotAt(epsilon(-1)).Add(
fooDist.fieldVal(field1, "val1a").dist(0, 1, 1, 2, 2, 3, 4, 5, 5, 6, 7, 8, 9, 25),
),
},
},
{
Name: "distribution cannot have number of samples regress",
Registration: newMetricRegistration(fooDist),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-3)).Add(
fooDist.dist(1, 2, 3, 4, 5, 6),
),
newSnapshotAt(epsilon(-2)).Add(
fooDist.dist(0, 1, 1, 2, 2, 3, 4, 5, 5, 6, 7, 8, 9, 25),
),
},
WantFail: newSnapshotAt(epsilon(-1)).Add(
fooDist.dist(0, 1, 2, 2, 3, 4, 5, 5, 6, 7, 8, 9),
),
},
{
Name: "distribution sum-of-squared-deviations must be a floating-point number",
Registration: newMetricRegistration(fooDist),
WantFail: newSnapshotAt(epsilon(-1)).Add(
&Data{
Metric: fooDist.metric(),
Labels: fooDist.labels(),
HistogramValue: &Histogram{
Buckets: fooDist.dist(1, 2, 3).HistogramValue.Buckets,
Min: fooDist.dist(1, 2, 3).HistogramValue.Min,
Max: fooDist.dist(1, 2, 3).HistogramValue.Max,
SumOfSquaredDeviations: Number{
Int: int64(fooDist.dist(1, 2, 3).HistogramValue.SumOfSquaredDeviations.Float),
},
},
},
),
},
{
Name: "distribution cannot have sum-of-squared-deviations regress",
Registration: newMetricRegistration(fooDist),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-2)).Add(
&Data{
Metric: fooDist.metric(),
Labels: fooDist.labels(),
HistogramValue: &Histogram{
Buckets: fooDist.dist(1, 2, 3).HistogramValue.Buckets,
Min: fooDist.dist(1, 2, 3).HistogramValue.Min,
Max: fooDist.dist(1, 2, 3).HistogramValue.Max,
SumOfSquaredDeviations: fooDist.dist(1, 2, 3).HistogramValue.SumOfSquaredDeviations,
},
},
),
},
WantFail: newSnapshotAt(epsilon(-1)).Add(
&Data{
Metric: fooDist.metric(),
Labels: fooDist.labels(),
HistogramValue: &Histogram{
Buckets: fooDist.dist(1, 2, 3).HistogramValue.Buckets,
Min: fooDist.dist(1, 2, 3).HistogramValue.Min,
Max: fooDist.dist(1, 2, 3).HistogramValue.Max,
SumOfSquaredDeviations: Number{
Float: fooDist.dist(1, 2, 3).HistogramValue.SumOfSquaredDeviations.Float - 1.0,
},
},
},
),
},
{
Name: "distribution cannot have minimum increase",
Registration: newMetricRegistration(fooDist),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-2)).Add(
&Data{
Metric: fooDist.metric(),
Labels: fooDist.labels(),
HistogramValue: &Histogram{
Buckets: fooDist.dist(1, 2, 3).HistogramValue.Buckets,
Min: fooDist.dist(1, 2, 3).HistogramValue.Min,
Max: fooDist.dist(1, 2, 3).HistogramValue.Max,
SumOfSquaredDeviations: fooDist.dist(1, 2, 3).HistogramValue.SumOfSquaredDeviations,
},
},
),
},
WantFail: newSnapshotAt(epsilon(-1)).Add(
&Data{
Metric: fooDist.metric(),
Labels: fooDist.labels(),
HistogramValue: &Histogram{
Buckets: fooDist.dist(1, 2, 3).HistogramValue.Buckets,
Min: Number{
Int: fooDist.dist(1, 2, 3).HistogramValue.Min.Int + 1,
},
Max: fooDist.dist(1, 2, 3).HistogramValue.Max,
SumOfSquaredDeviations: fooDist.dist(1, 2, 3).HistogramValue.SumOfSquaredDeviations,
},
},
),
},
{
Name: "distribution cannot have minimum value change type",
Registration: newMetricRegistration(fooDist),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-2)).Add(
&Data{
Metric: fooDist.metric(),
Labels: fooDist.labels(),
HistogramValue: &Histogram{
Buckets: fooDist.dist(1, 2, 3).HistogramValue.Buckets,
Min: Number{
Int: fooDist.dist(1, 2, 3).HistogramValue.Min.Int,
},
Max: fooDist.dist(1, 2, 3).HistogramValue.Max,
SumOfSquaredDeviations: fooDist.dist(1, 2, 3).HistogramValue.SumOfSquaredDeviations,
},
},
),
},
WantFail: newSnapshotAt(epsilon(-1)).Add(
&Data{
Metric: fooDist.metric(),
Labels: fooDist.labels(),
HistogramValue: &Histogram{
Buckets: fooDist.dist(1, 2, 3).HistogramValue.Buckets,
Min: Number{
Float: float64(fooDist.dist(1, 2, 3).HistogramValue.Min.Int),
},
Max: fooDist.dist(1, 2, 3).HistogramValue.Max,
SumOfSquaredDeviations: fooDist.dist(1, 2, 3).HistogramValue.SumOfSquaredDeviations,
},
},
),
},
{
Name: "distribution cannot have maximum decrease",
Registration: newMetricRegistration(fooDist),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-2)).Add(
&Data{
Metric: fooDist.metric(),
Labels: fooDist.labels(),
HistogramValue: &Histogram{
Buckets: fooDist.dist(1, 2, 3).HistogramValue.Buckets,
Min: fooDist.dist(1, 2, 3).HistogramValue.Min,
Max: fooDist.dist(1, 2, 3).HistogramValue.Max,
SumOfSquaredDeviations: fooDist.dist(1, 2, 3).HistogramValue.SumOfSquaredDeviations,
},
},
),
},
WantFail: newSnapshotAt(epsilon(-1)).Add(
&Data{
Metric: fooDist.metric(),
Labels: fooDist.labels(),
HistogramValue: &Histogram{
Buckets: fooDist.dist(1, 2, 3).HistogramValue.Buckets,
Min: fooDist.dist(1, 2, 3).HistogramValue.Min,
Max: Number{
Int: fooDist.dist(1, 2, 3).HistogramValue.Max.Int - 1,
},
SumOfSquaredDeviations: fooDist.dist(1, 2, 3).HistogramValue.SumOfSquaredDeviations,
},
},
),
},
{
Name: "distribution cannot have maximum value change type",
Registration: newMetricRegistration(fooDist),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-2)).Add(
&Data{
Metric: fooDist.metric(),
Labels: fooDist.labels(),
HistogramValue: &Histogram{
Buckets: fooDist.dist(1, 2, 3).HistogramValue.Buckets,
Min: fooDist.dist(1, 2, 3).HistogramValue.Min,
Max: Number{
Int: fooDist.dist(1, 2, 3).HistogramValue.Max.Int,
},
SumOfSquaredDeviations: fooDist.dist(1, 2, 3).HistogramValue.SumOfSquaredDeviations,
},
},
),
},
WantFail: newSnapshotAt(epsilon(-1)).Add(
&Data{
Metric: fooDist.metric(),
Labels: fooDist.labels(),
HistogramValue: &Histogram{
Buckets: fooDist.dist(1, 2, 3).HistogramValue.Buckets,
Min: fooDist.dist(1, 2, 3).HistogramValue.Min,
Max: Number{
Float: float64(fooDist.dist(1, 2, 3).HistogramValue.Max.Int),
},
SumOfSquaredDeviations: fooDist.dist(1, 2, 3).HistogramValue.SumOfSquaredDeviations,
},
},
),
},
{
Name: "distribution with zero samples",
Registration: newMetricRegistration(fooDist),
WantSuccess: []*Snapshot{newSnapshotAt(epsilon(-1)).Add(
&Data{
Metric: fooDist.metric(),
HistogramValue: &Histogram{
Buckets: []Bucket{
{UpperBound: Number{Int: 0}, Samples: 0},
{UpperBound: Number{Int: 1}, Samples: 0},
{UpperBound: Number{Int: 2}, Samples: 0},
{UpperBound: Number{Int: 4}, Samples: 0},
{UpperBound: Number{Int: 8}, Samples: 0},
{UpperBound: Number{Float: math.Inf(1)}, Samples: 0},
},
},
},
)},
},
{
Name: "distribution with manual samples",
Registration: newMetricRegistration(fooDist),
WantSuccess: []*Snapshot{newSnapshotAt(epsilon(-1)).Add(
&Data{
Metric: fooDist.metric(),
HistogramValue: &Histogram{
Total: Number{Int: 10},
Buckets: []Bucket{
{UpperBound: Number{Int: 0}, Samples: 2},
{UpperBound: Number{Int: 1}, Samples: 1},
{UpperBound: Number{Int: 2}, Samples: 3},
{UpperBound: Number{Int: 4}, Samples: 1},
{UpperBound: Number{Int: 8}, Samples: 4},
{UpperBound: Number{Float: math.Inf(1)}, Samples: 1},
},
},
},
)},
},
{
Name: "distribution gets bad number of buckets",
Registration: newMetricRegistration(fooDist),
WantFail: newSnapshotAt(epsilon(-1)).Add(
&Data{
Metric: fooDist.metric(),
HistogramValue: &Histogram{
Total: Number{Int: 10},
Buckets: []Bucket{
{UpperBound: Number{Int: 0}, Samples: 2},
{UpperBound: Number{Int: 1}, Samples: 1},
{UpperBound: Number{Int: 2}, Samples: 3},
// Missing: {UpperBound: Number{Int: 4}, Samples: 1},
{UpperBound: Number{Int: 8}, Samples: 4},
{UpperBound: Number{Float: math.Inf(1)}, Samples: 1},
},
},
},
),
},
{
Name: "distribution gets unexpected bucket boundary",
Registration: newMetricRegistration(fooDist),
WantFail: newSnapshotAt(epsilon(-1)).Add(
&Data{
Metric: fooDist.metric(),
HistogramValue: &Histogram{
Total: Number{Int: 10},
Buckets: []Bucket{
{UpperBound: Number{Int: 0}, Samples: 2},
{UpperBound: Number{Int: 1}, Samples: 1},
{UpperBound: Number{Int: 3 /* Should be 2 */}, Samples: 3},
{UpperBound: Number{Int: 4}, Samples: 1},
{UpperBound: Number{Int: 8}, Samples: 4},
{UpperBound: Number{Float: math.Inf(1)}, Samples: 1},
},
},
},
),
},
{
Name: "distribution gets unexpected last bucket boundary",
Registration: newMetricRegistration(fooDist),
WantFail: newSnapshotAt(epsilon(-1)).Add(
&Data{
Metric: fooDist.metric(),
HistogramValue: &Histogram{
Total: Number{Int: 10},
Buckets: []Bucket{
{UpperBound: Number{Int: 0}, Samples: 2},
{UpperBound: Number{Int: 1}, Samples: 1},
{UpperBound: Number{Int: 2}, Samples: 3},
{UpperBound: Number{Int: 4}, Samples: 1},
{UpperBound: Number{Int: 8}, Samples: 4},
{
UpperBound: Number{Float: math.Inf(-1) /* Should be +inf */},
Samples: 1,
},
},
},
},
),
},
{
Name: "partial incremental snapshot needing indirection",
Registration: newMetricRegistration(fooCounter),
WantSuccess: []*Snapshot{
newSnapshotAt(epsilon(-2)).Add(fooCounter.int(int64(maxDirectUint + 2))),
newSnapshotAt(epsilon(-1)).Add(),
newSnapshotAt(epsilon(0)).Add(fooCounter.int(int64(maxDirectUint + 3))),
},
},
{
Name: "worked example",
Registration: newMetricRegistration(
fooInt,
fooDist.withField(field1),
fooCounter.withField(field1, field2),
),
WantSuccess: []*Snapshot{
// Empty snapshot.
newSnapshotAt(epsilon(-6)),
// Simple snapshot.
newSnapshotAt(epsilon(-5)).Add(
fooInt.int(3),
fooDist.fieldVal(field1, "val1a").dist(1, 2, 3, 4, 5, 6),
fooDist.fieldVal(field1, "val1b").dist(-1, -8, 100),
fooCounter.fieldVals(map[*pb.MetricMetadata_Field]string{
field1: "val1a",
field2: "val2a",
}).int(6),
fooCounter.fieldVals(map[*pb.MetricMetadata_Field]string{
field1: "val1b",
field2: "val2a",
}).int(3),
),
// And another.
newSnapshotAt(epsilon(-4)).Add(
fooInt.int(1),
fooDist.fieldVal(field1, "val1a").dist(1, 2, 3, 4, 5, 6, 7),
fooDist.fieldVal(field1, "val1b").dist(-1, -8, 100, 42),
fooCounter.fieldVals(map[*pb.MetricMetadata_Field]string{
field1: "val1a",
field2: "val2a",
}).int(6),
fooCounter.fieldVals(map[*pb.MetricMetadata_Field]string{
field1: "val1b",
field2: "val2a",
}).int(4),
),
// And another one, partial this time.
newSnapshotAt(epsilon(-3)).Add(
fooDist.fieldVal(field1, "val1b").dist(-1, -8, 100, 42, 1337),
fooCounter.fieldVals(map[*pb.MetricMetadata_Field]string{
field1: "val1a",
field2: "val2a",
}).int(6),
),
// An empty one.
newSnapshotAt(epsilon(-2)),
// Another empty one at the same timestamp.
newSnapshotAt(epsilon(-1)),
// Another full one which doesn't change any value.
newSnapshotAt(epsilon(0)).Add(
fooInt.int(1),
fooDist.fieldVal(field1, "val1a").dist(1, 2, 3, 4, 5, 6, 7),
fooDist.fieldVal(field1, "val1b").dist(-1, -8, 100, 42, 1337),
fooCounter.fieldVals(map[*pb.MetricMetadata_Field]string{
field1: "val1a",
field2: "val2a",
}).int(6),
fooCounter.fieldVals(map[*pb.MetricMetadata_Field]string{
field1: "val1b",
field2: "val2a",
}).int(4),
),
},
},
} {
t.Run(test.Name, func(t *testing.T) {
testTime := test.At
if testTime.IsZero() {
testTime = testStart
}
at(testTime, func() {
t.Logf("Test is running with simulated time: %v", testTime)
verifier, cleanup, err := NewVerifier(test.Registration)
defer cleanup()
if err != nil && !test.WantVerifierCreationErr {
t.Fatalf("unexpected verifier creation error: %v", err)
}
if err == nil && test.WantVerifierCreationErr {
t.Fatal("verifier creation unexpectedly succeeded")
}
if err != nil {
t.Logf("Verifier creation failed (as expected by this test): %v", err)
return
}
if len(test.WantSuccess) == 0 && test.WantFail == nil {
if err = verifier.Verify(NewSnapshot()); err != nil {
t.Errorf("empty snapshot failed verification: %v", err)
}
} else {
for i, snapshot := range test.WantSuccess {
func() {
defer func() {
panicErr := recover()
t.Helper()
if panicErr != nil {
t.Fatalf("panic during verification of WantSuccess[%d] snapshot: %v", i, panicErr)
}
}()
if err = verifier.Verify(snapshot); err != nil {
t.Fatalf("snapshot WantSuccess[%d] failed verification: %v", i, err)
}
}()
}
if test.WantFail != nil {
func() {
defer func() {
panicErr := recover()
t.Helper()
if panicErr != nil {
t.Fatalf("panic during verification of WantFail snapshot: %v", panicErr)
}
}()
if err = verifier.Verify(test.WantFail); err == nil {
t.Error("WantFail snapshot unexpectedly succeeded verification")
} else {
t.Logf("WantFail snapshot failed verification (as expected by this test): %v", err)
}
}()
}
}
})
})
}
}
// shortWriter implements io.StringWriter but fails after a given number of bytes.
type shortWriter struct {
buf strings.Builder
size int
maxSize int
}
// Reset erases buffer data and resets the shortWriter to the given size.
func (s *shortWriter) Reset(size int) {
s.buf.Reset()
s.size = 0
s.maxSize = size
}
// String returns the buffered data as a string.
func (s *shortWriter) String() string {
return s.buf.String()
}
// Write implements io.StringWriter.WriteString.
func (s *shortWriter) WriteString(x string) (n int, err error) {
toWrite := len(x)
leftToWrite := s.maxSize - s.size
if leftToWrite < toWrite {
toWrite = leftToWrite
}
if toWrite == 0 {
return 0, errors.New("writer out of capacity")
}
written, err := s.buf.WriteString(x[:toWrite])
s.size += written
if written == len(x) {
return written, err
}
return written, errors.New("short write")
}
// reflectProto converts a v1 or v2 proto message to a proto message with
// reflection enabled.
func reflectProto(m any) protoreflect.ProtoMessage {
if msg, hasReflection := m.(proto.Message); hasReflection {
return msg
}
// Convert v1 proto to introspectable view, if possible and necessary.
if v1pb, ok := m.(v1proto.Message); ok {
return v1proto.MessageReflect(v1pb).Interface()
}
panic(fmt.Sprintf("Proto message %v isn't of a supported protobuf type", m))
}
// TestSnapshotToPrometheus verifies that the contents of a Snapshot can be
// converted into text that can be parsed by the Prometheus parsing libraries,
// and produces the data we expect them to.
func TestSnapshotToPrometheus(t *testing.T) {
singleLineFormatter := &prototext.MarshalOptions{Multiline: false, EmitUnknown: true}
multiLineFormatter := &prototext.MarshalOptions{Multiline: true, Indent: " ", EmitUnknown: true}
testStart := time.Now()
newSnapshot := func() *Snapshot {
return newSnapshotAt(testStart)
}
for _, test := range []struct {
Name string
// Snapshot will be rendered as Prometheus and compared against WantData.
Snapshot *Snapshot
// ExportOptions dictates the options used during overall rendering.
ExportOptions ExportOptions
// SnapshotExportOptions dictates the options used during Snapshot rendering.
SnapshotExportOptions SnapshotExportOptions
// WantFail, if true, indicates that the test is expected to fail when
// rendering or parsing the snapshot data.
WantFail bool
// WantData is Prometheus text format that matches the data in Snapshot.
// The substring "{TIMESTAMP}" will be replaced with the value of
// `testStart` in milliseconds.
WantData string
}{
{
Name: "empty snapshot",
Snapshot: newSnapshot(),
},
{
Name: "simple integer",
Snapshot: newSnapshot().Add(fooInt.int(3)),
WantData: `
# HELP foo_int An integer about foo
# TYPE foo_int gauge
foo_int 3 {TIMESTAMP}
`,
},
{
Name: "simple float",
Snapshot: newSnapshot().Add(fooInt.float(2.5)),
WantData: `
# HELP foo_int An integer about foo
# TYPE foo_int gauge
foo_int 2.5 {TIMESTAMP}
`,
},
{
Name: "simple counter",
Snapshot: newSnapshot().Add(fooCounter.int(4)),
WantData: `
# HELP foo_counter A counter of foos
# TYPE foo_counter counter
foo_counter 4 {TIMESTAMP}
`,
},
{
Name: "two metrics",
Snapshot: newSnapshot().Add(
// Note the different order here than in WantData,
// to test ordering independence.
fooCounter.int(4),
fooInt.int(3),
),
WantData: `
# HELP foo_int An integer about foo
# TYPE foo_int gauge
foo_int 3 {TIMESTAMP}
# HELP foo_counter A counter of foos
# TYPE foo_counter counter
foo_counter 4 {TIMESTAMP}
`,
},
{
Name: "metric with 1 field",
Snapshot: newSnapshot().Add(
fooInt.fieldVal(field1, "val1a").int(3),
fooInt.fieldVal(field1, "val1b").int(7),
),
WantData: `
# HELP foo_int An integer about foo
# TYPE foo_int gauge
foo_int{field1="val1a"} 3 {TIMESTAMP}
foo_int{field1="val1b"} 7 {TIMESTAMP}
`,
},
{
Name: "metric with 2 fields",
Snapshot: newSnapshot().Add(
fooInt.fieldVal(field1, "val1a").fieldVal(field2, "val2a").int(3),
fooInt.fieldVal(field2, "val2b").fieldVal(field1, "val1b").int(7),
),
WantData: `
# HELP foo_int An integer about foo
# TYPE foo_int gauge
foo_int{field1="val1a",field2="val2a"} 3 {TIMESTAMP}
foo_int{field1="val1b",field2="val2b"} 7 {TIMESTAMP}
`,
},
{
Name: "simple integer with export options",
Snapshot: newSnapshot().Add(fooInt.int(3)),
ExportOptions: ExportOptions{
CommentHeader: "Some header",
},
SnapshotExportOptions: SnapshotExportOptions{
ExporterPrefix: "some_prefix_",
ExtraLabels: map[string]string{
"field3": "val3a",
},
},
WantData: `
# HELP some_prefix_foo_int An integer about foo
# TYPE some_prefix_foo_int gauge
some_prefix_foo_int{field3="val3a"} 3 {TIMESTAMP}
`,
},
{
Name: "integer with fields mixing with export options",
Snapshot: newSnapshot().Add(
fooInt.fieldVal(field1, "val1a").fieldVal(field2, "val2a").int(3),
fooInt.fieldVal(field2, "val2b").fieldVal(field1, "val1b").int(7),
),
SnapshotExportOptions: SnapshotExportOptions{
ExtraLabels: map[string]string{
"field3": "val3a",
},
},
WantData: `
# HELP foo_int An integer about foo
# TYPE foo_int gauge
foo_int{field1="val1a",field2="val2a",field3="val3a"} 3 {TIMESTAMP}
foo_int{field1="val1b",field2="val2b",field3="val3a"} 7 {TIMESTAMP}
`,
},
{
Name: "integer with fields conflicting with export options",
Snapshot: newSnapshot().Add(
fooInt.fieldVal(field1, "val1a").fieldVal(field2, "val2a").int(3),
fooInt.fieldVal(field2, "val2b").fieldVal(field1, "val1b").int(7),
),
SnapshotExportOptions: SnapshotExportOptions{
ExtraLabels: map[string]string{
"field2": "val2c",
"field3": "val3a",
},
},
WantFail: true,
},
{
Name: "simple distribution",
Snapshot: newSnapshot().Add(
// -1 + 3 + 3 + 3 + 5 + 7 + 7 + 99 = 126
fooDist.dist(-1, 3, 3, 3, 5, 7, 7, 99),
),
WantData: `
# HELP foo_dist A distribution about foo
# TYPE foo_dist histogram
foo_dist_bucket{le="0"} 1 {TIMESTAMP}
foo_dist_bucket{le="1"} 1 {TIMESTAMP}
foo_dist_bucket{le="2"} 1 {TIMESTAMP}
foo_dist_bucket{le="4"} 4 {TIMESTAMP}
foo_dist_bucket{le="8"} 7 {TIMESTAMP}
foo_dist_bucket{le="+inf"} 8 {TIMESTAMP}
foo_dist_sum 126 {TIMESTAMP}
foo_dist_count 8 {TIMESTAMP}
foo_dist_min -1 {TIMESTAMP}
foo_dist_max 99 {TIMESTAMP}
foo_dist_ssd 8187.5 {TIMESTAMP}
`,
},
{
Name: "distribution with 'le' label",
Snapshot: newSnapshot().Add(
fooDist.fieldVal(&pb.MetricMetadata_Field{
FieldName: "le",
AllowedValues: []string{"foo"},
}, "foo").dist(-1, 3, 3, 3, 5, 7, 7, 99),
),
WantFail: true,
},
{
Name: "distribution with no samples",
Snapshot: newSnapshot().Add(
fooDist.dist(),
),
WantData: `
# HELP foo_dist A distribution about foo
# TYPE foo_dist histogram
foo_dist_bucket{le="0"} 0 {TIMESTAMP}
foo_dist_bucket{le="1"} 0 {TIMESTAMP}
foo_dist_bucket{le="2"} 0 {TIMESTAMP}
foo_dist_bucket{le="4"} 0 {TIMESTAMP}
foo_dist_bucket{le="8"} 0 {TIMESTAMP}
foo_dist_bucket{le="+inf"} 0 {TIMESTAMP}
foo_dist_sum 0 {TIMESTAMP}
foo_dist_count 0 {TIMESTAMP}
foo_dist_min 0 {TIMESTAMP}
foo_dist_max 0 {TIMESTAMP}
foo_dist_ssd 0 {TIMESTAMP}
`,
},
{
Name: "distribution with 1 field",
Snapshot: newSnapshot().Add(
// -1 + 3 + 3 + 3 + 5 + 7 + 7 + 99 = 126
fooDist.fieldVal(field1, "val1a").dist(-1, 3, 3, 3, 5, 7, 7, 99),
// 3 + 5 + 3 = 11
fooDist.fieldVal(field1, "val1b").dist(3, 5, 3),
),
WantData: `
# HELP foo_dist A distribution about foo
# TYPE foo_dist histogram
foo_dist_bucket{field1="val1a",le="0"} 1 {TIMESTAMP}
foo_dist_bucket{field1="val1a",le="1"} 1 {TIMESTAMP}
foo_dist_bucket{field1="val1a",le="2"} 1 {TIMESTAMP}
foo_dist_bucket{field1="val1a",le="4"} 4 {TIMESTAMP}
foo_dist_bucket{field1="val1a",le="8"} 7 {TIMESTAMP}
foo_dist_bucket{field1="val1a",le="+inf"} 8 {TIMESTAMP}
foo_dist_sum{field1="val1a"} 126 {TIMESTAMP}
foo_dist_count{field1="val1a"} 8 {TIMESTAMP}
foo_dist_min{field1="val1a"} -1 {TIMESTAMP}
foo_dist_max{field1="val1a"} 99 {TIMESTAMP}
foo_dist_ssd{field1="val1a"} 8187.5 {TIMESTAMP}
foo_dist_bucket{field1="val1b",le="0"} 0 {TIMESTAMP}
foo_dist_bucket{field1="val1b",le="1"} 0 {TIMESTAMP}
foo_dist_bucket{field1="val1b",le="2"} 0 {TIMESTAMP}
foo_dist_bucket{field1="val1b",le="4"} 2 {TIMESTAMP}
foo_dist_bucket{field1="val1b",le="8"} 3 {TIMESTAMP}
foo_dist_bucket{field1="val1b",le="+inf"} 3 {TIMESTAMP}
foo_dist_sum{field1="val1b"} 11 {TIMESTAMP}
foo_dist_count{field1="val1b"} 3 {TIMESTAMP}
foo_dist_min{field1="val1b"} 3 {TIMESTAMP}
foo_dist_max{field1="val1b"} 5 {TIMESTAMP}
foo_dist_ssd{field1="val1b"} 8.25 {TIMESTAMP}
`,
},
{
Name: "distribution with 2 fields, one from ExportOptions",
Snapshot: newSnapshot().Add(
// -1 + 3 + 3 + 3 + 5 + 7 + 7 + 99 = 126
fooDist.fieldVal(field1, "val1a").dist(-1, 3, 3, 3, 5, 7, 7, 99),
// 3 + 5 + 3 = 11
fooDist.fieldVal(field1, "val1b").dist(3, 5, 3),
),
ExportOptions: ExportOptions{
CommentHeader: "Some header",
},
SnapshotExportOptions: SnapshotExportOptions{
ExporterPrefix: "some_prefix_",
ExtraLabels: map[string]string{"field2": "val2a"},
},
WantData: `
# HELP some_prefix_foo_dist A distribution about foo
# TYPE some_prefix_foo_dist histogram
some_prefix_foo_dist_bucket{field1="val1a",field2="val2a",le="0"} 1 {TIMESTAMP}
some_prefix_foo_dist_bucket{field1="val1a",field2="val2a",le="1"} 1 {TIMESTAMP}
some_prefix_foo_dist_bucket{field1="val1a",field2="val2a",le="2"} 1 {TIMESTAMP}
some_prefix_foo_dist_bucket{field1="val1a",field2="val2a",le="4"} 4 {TIMESTAMP}
some_prefix_foo_dist_bucket{field1="val1a",field2="val2a",le="8"} 7 {TIMESTAMP}
some_prefix_foo_dist_bucket{field1="val1a",field2="val2a",le="+inf"} 8 {TIMESTAMP}
some_prefix_foo_dist_sum{field1="val1a",field2="val2a"} 126 {TIMESTAMP}
some_prefix_foo_dist_count{field1="val1a",field2="val2a"} 8 {TIMESTAMP}
some_prefix_foo_dist_min{field1="val1a",field2="val2a"} -1 {TIMESTAMP}
some_prefix_foo_dist_max{field1="val1a",field2="val2a"} 99 {TIMESTAMP}
some_prefix_foo_dist_ssd{field1="val1a",field2="val2a"} 8187.5 {TIMESTAMP}
some_prefix_foo_dist_bucket{field1="val1b",field2="val2a",le="0"} 0 {TIMESTAMP}
some_prefix_foo_dist_bucket{field1="val1b",field2="val2a",le="1"} 0 {TIMESTAMP}
some_prefix_foo_dist_bucket{field1="val1b",field2="val2a",le="2"} 0 {TIMESTAMP}
some_prefix_foo_dist_bucket{field1="val1b",field2="val2a",le="4"} 2 {TIMESTAMP}
some_prefix_foo_dist_bucket{field1="val1b",field2="val2a",le="8"} 3 {TIMESTAMP}
some_prefix_foo_dist_bucket{field1="val1b",field2="val2a",le="+inf"} 3 {TIMESTAMP}
some_prefix_foo_dist_sum{field1="val1b",field2="val2a"} 11 {TIMESTAMP}
some_prefix_foo_dist_count{field1="val1b",field2="val2a"} 3 {TIMESTAMP}
some_prefix_foo_dist_min{field1="val1b",field2="val2a"} 3 {TIMESTAMP}
some_prefix_foo_dist_max{field1="val1b",field2="val2a"} 5 {TIMESTAMP}
some_prefix_foo_dist_ssd{field1="val1b",field2="val2a"} 8.25 {TIMESTAMP}
`,
},
} {
t.Run(test.Name, func(t *testing.T) {
// Render and parse snapshot data.
var buf bytes.Buffer
snapshotToOptions := map[*Snapshot]SnapshotExportOptions{test.Snapshot: test.SnapshotExportOptions}
if _, err := Write(&buf, test.ExportOptions, snapshotToOptions); err != nil {
if test.WantFail {
return
}
t.Fatalf("cannot write snapshot: %v", err)
}
gotMetricsRaw := buf.String()
gotMetrics, err := (&expfmt.TextParser{}).TextToMetricFamilies(&buf)
if err != nil {
if test.WantFail {
return
}
t.Fatalf("cannot parse data written from snapshot: %v", err)
}
if test.WantFail {
t.Fatalf("Test unexpectedly succeeded to render and parse snapshot data")
}
// Verify that the data is consistent (i.e. verify that it's not based on random map ordering)
var buf2 bytes.Buffer
if _, err := Write(&buf2, test.ExportOptions, snapshotToOptions); err != nil {
if test.WantFail {
return
}
t.Fatalf("cannot write snapshot: %v", err)
}
gotMetricsRaw2 := buf2.String()
if gotMetricsRaw != gotMetricsRaw2 {
t.Errorf("inconsistent snapshot rendering:\n\n%s\n\n---- VS ----\n\n%s\n\n", gotMetricsRaw, gotMetricsRaw2)
}
// Verify that error propagation works by having the writer fail at each possible spot.
// This exercises all the write error propagation branches.
var shortWriter shortWriter
for writeLength := 0; writeLength < len(gotMetricsRaw); writeLength++ {
shortWriter.Reset(writeLength)
if _, err := Write(&shortWriter, test.ExportOptions, snapshotToOptions); err == nil {
t.Fatalf("snapshot data unexpectedly succeeded being written to short writer (length %d): %v", writeLength, shortWriter.String())
}
if shortWriter.size != writeLength {
t.Fatalf("Short writer should have allowed %d bytes of snapshot data to be written, but actual number of bytes written is %d bytes", writeLength, shortWriter.size)
}
}
// Parse reference data.
wantData := strings.ReplaceAll(test.WantData, "{TIMESTAMP}", fmt.Sprintf("%d", testStart.UnixMilli()))
wantMetrics, err := (&expfmt.TextParser{}).TextToMetricFamilies(strings.NewReader(wantData))
if err != nil {
t.Fatalf("cannot parse reference data: %v", err)
}
if len(test.Snapshot.Data) != 0 {
// If the snapshot isn't empty, verify that the data we got from both `got` and `want`
// is non-zero. Otherwise, this whole test could accidentally succeed by having all attempts
// at parsing the data result into an empty set.
if len(wantMetrics) == 0 {
t.Error("Snapshot is not empty, but parsing the reference data resulted in no data being produced")
}
if len(gotMetrics) == 0 {
t.Error("Snapshot is not empty, but parsing the rendered snapshot resulted in no data being produced")
}
}
// Verify that all of `wantMetrics` is in `gotMetrics`.
for metric, want := range wantMetrics {
if _, found := gotMetrics[metric]; !found {
wantText, err := singleLineFormatter.Marshal(reflectProto(want))
if err != nil {
t.Fatalf("cannot marshal reference data: %v", err)
}
t.Errorf("metric %s is in reference data (%v) but not present in snapshot data", metric, string(wantText))
}
}
// Verify that all of `gotMetrics` is in `wantMetrics`.
for metric, got := range gotMetrics {
if _, found := wantMetrics[metric]; !found {
gotText, err := singleLineFormatter.Marshal(reflectProto(got))
if err != nil {
t.Fatalf("cannot marshal snapshot data: %v", err)
}
t.Errorf("metric %s found in snapshot data (%v) but not present in reference data", metric, string(gotText))
}
}
// The rest of the test assumes the keys are the same.
if t.Failed() {
return
}
// Verify metric data matches.
for metric := range wantMetrics {
t.Run(metric, func(t *testing.T) {
want := reflectProto(wantMetrics[metric])
got := reflectProto(gotMetrics[metric])
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
wantText, err := multiLineFormatter.Marshal(want)
if err != nil {
t.Fatalf("cannot marshal reference data: %v", err)
}
gotText, err := multiLineFormatter.Marshal(got)
if err != nil {
t.Fatalf("cannot marshal snapshot data: %v", err)
}
t.Errorf("Snapshot data did not produce the same data as the reference data.\n\nReference data:\n\n%v\n\nSnapshot data:\n\n%v\n\nDiff:\n\n%v\n\n", string(wantText), string(gotText), diff)
}
})
}
})
}
}
func TestWriteMultipleSnapshots(t *testing.T) {
testStart := time.Now()
snapshot1 := newSnapshotAt(testStart).Add(fooInt.int(3))
snapshot2 := newSnapshotAt(testStart.Add(3 * time.Minute)).Add(fooInt.int(5))
var buf bytes.Buffer
Write(&buf, ExportOptions{CommentHeader: "A header\non two lines"}, map[*Snapshot]SnapshotExportOptions{
snapshot1: {ExporterPrefix: "export_"},
snapshot2: {ExporterPrefix: "export_"},
})
fooIntName := "export_" + fooInt.PB.GetPrometheusName()
gotData, err := (&expfmt.TextParser{}).TextToMetricFamilies(&buf)
if err != nil {
t.Fatalf("cannot parse data written from snapshots: %v", err)
}
if len(gotData) != 1 || gotData[fooIntName] == nil {
t.Fatalf("unexpected data: %v", gotData)
}
sort.Slice(gotData[fooIntName].Metric, func(i, j int) bool {
return gotData[fooIntName].Metric[i].GetTimestampMs() < gotData[fooIntName].Metric[j].GetTimestampMs()
})
got := reflectProto(gotData[fooIntName])
var wantBuf bytes.Buffer
io.WriteString(&wantBuf, fmt.Sprintf(`
# HELP export_foo_int An integer about foo
# TYPE export_foo_int gauge
export_foo_int 3 %d
export_foo_int 5 %d
`, testStart.UnixMilli(), testStart.Add(3*time.Minute).UnixMilli()))
wantData, err := (&expfmt.TextParser{}).TextToMetricFamilies(&wantBuf)
if err != nil {
t.Fatalf("cannot parse reference data: %v", err)
}
if len(wantData) != 1 || wantData[fooIntName] == nil {
t.Fatalf("unexpected reference data: %v", gotData)
}
sort.Slice(wantData[fooIntName].Metric, func(i, j int) bool {
return wantData[fooIntName].Metric[i].GetTimestampMs() < wantData[fooIntName].Metric[j].GetTimestampMs()
})
want := reflectProto(wantData[fooIntName])
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
multiLineFormatter := &prototext.MarshalOptions{Multiline: true, Indent: " ", EmitUnknown: true}
wantText, err := multiLineFormatter.Marshal(want)
if err != nil {
t.Fatalf("cannot marshal reference data: %v", err)
}
gotText, err := multiLineFormatter.Marshal(got)
if err != nil {
t.Fatalf("cannot marshal snapshot data: %v", err)
}
t.Errorf("Snapshot data did not produce the same data as the reference data.\n\nReference data:\n\n%v\n\nSnapshot data:\n\n%v\n\nDiff:\n\n%v\n\n", string(wantText), string(gotText), diff)
}
}
func TestGroupSameNameMetrics(t *testing.T) {
snapshot1 := NewSnapshot().Add(
fooCounter.int(3),
fooInt.int(3),
fooDist.dist(0, 1),
)
snapshot2 := NewSnapshot().Add(
fooDist.dist(1, 2),
fooCounter.int(2),
)
snapshot3 := NewSnapshot().Add(
fooDist.dist(1, 2),
fooCounter.int(2),
)
var buf bytes.Buffer
_, err := Write(&buf, ExportOptions{}, map[*Snapshot]SnapshotExportOptions{
snapshot1: {ExporterPrefix: "my_little_prefix_", ExtraLabels: map[string]string{"snap": "1"}},
snapshot2: {ExporterPrefix: "my_little_prefix_", ExtraLabels: map[string]string{"snap": "2"}},
snapshot3: {ExporterPrefix: "not_the_same_prefix_", ExtraLabels: map[string]string{"snap": "1"}},
})
if err != nil {
t.Fatalf("Cannot write snapshot data: %v", err)
}
rawData := buf.String() // Capture the data written.
// Make sure the data written does parse.
// We don't use this result here because the Prometheus library is more permissive than this test.
if _, err := (&expfmt.TextParser{}).TextToMetricFamilies(&buf); err != nil {
t.Fatalf("cannot parse data written from snapshots: %v\nraw data:\n%s\n(end of raw data)", err, rawData)
}
// Verify that we see all metrics, and that each time we see a new one, it's one we haven't seen
// before.
seenMetrics := map[string]bool{}
var lastMetric string
for lineNumber, line := range strings.Split(rawData, "\n") {
t.Logf("Line %d: %q", lineNumber+1, line)
if strings.TrimSpace(line) == "" || strings.HasPrefix(line, "#") {
continue
}
strippedMetricName := strings.TrimLeftFunc(line, func(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
})
if len(strippedMetricName) == 0 {
t.Fatalf("invalid line: %q", line)
}
if strippedMetricName[0] != '{' && strippedMetricName[0] != ' ' {
t.Fatalf("invalid line: %q", line)
}
metricName := line[:len(line)-len(strippedMetricName)]
for _, distribSuffix := range []string{"_sum", "_count", "_bucket", "_min", "_max", "_ssd"} {
metricName = strings.TrimSuffix(metricName, distribSuffix)
}
if lastMetric != "" && lastMetric != metricName && seenMetrics[metricName] {
t.Fatalf("line %q: got already-seen metric name %q yet it is not the last metric (%s)", line, metricName, lastMetric)
}
lastMetric = metricName
seenMetrics[metricName] = true
}
wantSeenMetrics := map[string]bool{
fmt.Sprintf("my_little_prefix_%s", fooCounter.PB.GetPrometheusName()): true,
fmt.Sprintf("my_little_prefix_%s", fooInt.PB.GetPrometheusName()): true,
fmt.Sprintf("my_little_prefix_%s", fooDist.PB.GetPrometheusName()): true,
fmt.Sprintf("not_the_same_prefix_%s", fooCounter.PB.GetPrometheusName()): true,
fmt.Sprintf("not_the_same_prefix_%s", fooDist.PB.GetPrometheusName()): true,
}
if !cmp.Equal(seenMetrics, wantSeenMetrics) {
t.Errorf("Seen metrics: %v\nWant metrics: %v", seenMetrics, wantSeenMetrics)
}
}
func TestNumberPacker(t *testing.T) {
interestingIntegers := map[uint64]struct{}{
uint64(0): {},
uint64(0x5555555555555555): {},
uint64(0xaaaaaaaaaaaaaaaa): {},
uint64(0xffffffffffffffff): {},
}
for numBits := 0; numBits < 2; numBits++ {
newIntegers := map[uint64]struct{}{}
for interestingInt := range interestingIntegers {
for i := 0; i < 64; i++ {
newIntegers[interestingInt|(1<<i)] = struct{}{}
newIntegers[interestingInt & ^(1<<i)] = struct{}{}
}
}
for newInt := range newIntegers {
interestingIntegers[newInt] = struct{}{}
}
}
for _, i := range []int64{
0,
-1,
math.MinInt,
math.MaxInt,
math.MinInt8,
math.MaxInt8,
math.MaxUint8,
math.MinInt16,
math.MaxInt16,
math.MaxUint16,
math.MinInt32,
math.MaxInt32,
math.MaxUint32,
math.MinInt64,
math.MaxInt64,
int64(maxDirectUint),
} {
for d := int64(-3); d <= int64(3); d++ {
interestingIntegers[uint64(i+d)] = struct{}{}
}
}
interestingIntegers[0] = struct{}{}
interestingIntegers[1] = struct{}{}
interestingIntegers[2] = struct{}{}
interestingIntegers[3] = struct{}{}
interestingIntegers[math.MaxUint64-3] = struct{}{}
interestingIntegers[math.MaxUint64-2] = struct{}{}
interestingIntegers[math.MaxUint64-1] = struct{}{}
interestingIntegers[math.MaxUint64] = struct{}{}
interestingFloats := make(map[float64]struct{}, len(interestingIntegers)+21*21+17)
for divExp := -10; divExp < 10; divExp++ {
div := math.Pow(10, float64(divExp))
for i := -10; i < 10; i++ {
interestingFloats[float64(i)*div] = struct{}{}
}
}
interestingFloats[0.0] = struct{}{}
interestingFloats[math.NaN()] = struct{}{}
interestingFloats[math.Inf(1)] = struct{}{}
interestingFloats[math.Inf(-1)] = struct{}{}
interestingFloats[math.Pi] = struct{}{}
interestingFloats[math.Sqrt2] = struct{}{}
interestingFloats[math.E] = struct{}{}
interestingFloats[math.SqrtE] = struct{}{}
interestingFloats[math.Ln2] = struct{}{}
interestingFloats[math.MaxFloat32] = struct{}{}
interestingFloats[-math.MaxFloat32] = struct{}{}
interestingFloats[math.MaxFloat64] = struct{}{}
interestingFloats[-math.MaxFloat64] = struct{}{}
interestingFloats[math.SmallestNonzeroFloat32] = struct{}{}
interestingFloats[-math.SmallestNonzeroFloat32] = struct{}{}
interestingFloats[math.SmallestNonzeroFloat64] = struct{}{}
interestingFloats[-math.SmallestNonzeroFloat64] = struct{}{}
for interestingInt := range interestingIntegers {
interestingFloats[math.Float64frombits(interestingInt)] = struct{}{}
}
p := &numberPacker{
data: make([]uint64, 0, len(interestingIntegers)+len(interestingFloats)),
}
t.Run("integers", func(t *testing.T) {
seenDirectInteger := false
seenIndirectInteger := false
for interestingInt := range interestingIntegers {
orig := NewInt(int64(interestingInt))
packed := p.pack(orig)
unpacked := p.unpack(packed)
if !orig.SameType(unpacked) || orig.Int != unpacked.Int {
t.Errorf("integer %v (bits=%x): got packed=%v => unpacked version %v (int: %d)", orig, interestingInt, uint32(packed), unpacked, unpacked.Int)
}
needsIndirection := needsPackerStorage(orig)
switch uint32(packed) & storageField {
case storageFieldDirect:
seenDirectInteger = true
if needsIndirection != 0 {
t.Errorf("integer %v (bits=%x): got needsIndirection=%v want %v", orig, interestingInt, needsIndirection, 0)
}
case storageFieldIndirect:
seenIndirectInteger = true
if needsIndirection != 1 {
t.Errorf("integer %v (bits=%x): got needsIndirection=%v want %v", orig, interestingInt, needsIndirection, 1)
}
}
}
if !seenDirectInteger {
t.Error("did not encounter any integer that could be packed directly")
}
if !seenIndirectInteger {
t.Error("did not encounter any integer that was packed indirectly")
}
})
t.Run("packing_efficiency", func(t *testing.T) {
// Verify that we actually saved space by not packing every number in numberPacker itself.
if len(p.data) >= len(interestingIntegers) {
t.Errorf("packer had %d data points stored in its data, but we expected some of it to not be stored in it (tried to pack %d integers total)", len(p.data), len(interestingIntegers))
}
})
t.Run("floats", func(t *testing.T) {
seenDirectFloat := false
seenIndirectFloat := false
for interestingFloat := range interestingFloats {
orig := NewFloat(interestingFloat)
packed := p.pack(orig)
unpacked := p.unpack(packed)
switch {
case interestingFloat == 0: // Zero-valued float becomes an integer.
if !unpacked.IsInteger() {
t.Errorf("Zero-valued float %v: got non-integer number: %v", orig, unpacked)
} else if unpacked.Int != 0 {
t.Errorf("Zero-valued float %v: got non-zero integer: %d", orig, unpacked.Int)
}
case math.IsNaN(orig.Float):
if !math.IsNaN(unpacked.Float) {
t.Errorf("NaN float %v: got non-NaN unpacked version %v", orig, unpacked)
}
default: // Not NaN, not integer
if !orig.SameType(unpacked) || orig.Float != unpacked.Float {
t.Errorf("float %v (64bits=%x, 32bits=%x, float32-encodable=%v): got packed=%x => unpacked version %v (float: %f)", orig, math.Float64bits(interestingFloat), math.Float32bits(float32(interestingFloat)), float64(float32(interestingFloat)) == interestingFloat, uint32(packed), unpacked, unpacked.Float)
}
}
needsIndirection := needsPackerStorage(orig)
switch uint32(packed) & storageField {
case storageFieldDirect:
seenDirectFloat = true
if needsIndirection != 0 {
t.Errorf("float %v (64bits=%x): got needsIndirection=%v want %v", orig, math.Float64bits(interestingFloat), needsIndirection, 0)
}
case storageFieldIndirect:
seenIndirectFloat = true
if needsIndirection != 1 {
t.Errorf("float %v (bits=%x): got needsIndirection=%v want %v", orig, math.Float64bits(interestingFloat), needsIndirection, 1)
}
}
}
if !seenDirectFloat {
t.Error("did not encounter any float that could be packed directly")
}
if !seenIndirectFloat {
t.Error("did not encounter any float that was packed indirectly")
}
})
}
func TestNumberPackerCapacity(t *testing.T) {
packer := &numberPacker{
data: make([]uint64, 0, 2),
}
checkPanic := func(want bool, fn func()) {
t.Helper()
defer func() {
panicErr := recover()
t.Helper()
if want && panicErr == nil {
t.Error("function did not panic but wanted it to")
} else if !want && panicErr != nil {
t.Errorf("function unexpectedly panic'd: %v", panicErr)
}
}()
fn()
}
t.Run("number that does not need indirection", func(t *testing.T) {
checkPanic(false, func() {
packer.pack(&Number{Int: 1})
})
})
t.Run("first number that needs indirection fits", func(t *testing.T) {
checkPanic(false, func() {
packer.pack(&Number{Int: int64(maxDirectUint + 3)})
})
})
t.Run("second number that needs indirection also fits", func(t *testing.T) {
checkPanic(false, func() {
packer.pack(&Number{Int: int64(maxDirectUint + 2)})
})
})
t.Run("third number that needs indirection does not", func(t *testing.T) {
checkPanic(true, func() {
packer.pack(&Number{Int: int64(maxDirectUint + 1)})
})
})
t.Run("second number that does not need indirection still fits", func(t *testing.T) {
checkPanic(false, func() {
packer.pack(&Number{Int: int64(maxDirectUint)})
})
})
}