diff --git a/docs/content/docs/install/metrics.md b/docs/content/docs/install/metrics.md index 6589c962a..72d71eb05 100644 --- a/docs/content/docs/install/metrics.md +++ b/docs/content/docs/install/metrics.md @@ -10,6 +10,7 @@ The metrics for pipelines-as-code can be accessed through the `pipelines-as-code pipelines-as-code supports various exporters, such as Prometheus, Google Stackdriver, and more. You can configure these exporters by referring to the [observability configuration](../config/config-observability.yaml). -| Name | Type | Description | -| ---------- |---------|-----------------------------------------------------| -| `pipelines_as_code_pipelinerun_count` | Counter | Number of pipelineruns created by pipelines-as-code | +| Name | Type | Description | +|------------------------------------------------------|---------|--------------------------------------------------------------------| +| `pipelines_as_code_pipelinerun_count` | Counter | Number of pipelineruns created by pipelines-as-code | +| `pipelines_as_code_pipelinerun_duration_seconds_sum` | Counter | Number of seconds all pipelineruns have taken in pipelines-as-code | diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index e08ea1e2f..5e45dea01 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -15,6 +15,10 @@ var prCount = stats.Float64("pipelines_as_code_pipelinerun_count", "number of pipeline runs by pipelines as code", stats.UnitDimensionless) +var prDurationCount = stats.Float64("pipelines_as_code_pipelinerun_duration_seconds_sum", + "number of seconds all pipelineruns completed in by pipelines as code", + stats.UnitDimensionless) + // Recorder holds keys for metrics. type Recorder struct { initialized bool @@ -22,6 +26,8 @@ type Recorder struct { eventType tag.Key namespace tag.Key repository tag.Key + status tag.Key + reason tag.Key ReportingPeriod time.Duration } @@ -59,6 +65,18 @@ func NewRecorder() (*Recorder, error) { } r.repository = repository + status, err := tag.NewKey("status") + if err != nil { + return nil, err + } + r.status = status + + reason, err := tag.NewKey("reason") + if err != nil { + return nil, err + } + r.reason = reason + err = view.Register( &view.View{ Description: prCount.Description(), @@ -66,6 +84,12 @@ func NewRecorder() (*Recorder, error) { Aggregation: view.Count(), TagKeys: []tag.Key{r.provider, r.eventType, r.namespace, r.repository}, }, + &view.View{ + Description: prDurationCount.Description(), + Measure: prDurationCount, + Aggregation: view.Sum(), + TagKeys: []tag.Key{r.namespace, r.repository, r.status, r.reason}, + }, ) if err != nil { r.initialized = false @@ -96,3 +120,25 @@ func (r *Recorder) Count(provider, event, namespace, repository string) error { metrics.Record(ctx, prCount.M(1)) return nil } + +// CountPRDuration collects duration taken by a pipelinerun in seconds accumulate them in prDurationCount. +func (r *Recorder) CountPRDuration(namespace, repository, status, reason string, duration time.Duration) error { + if !r.initialized { + return fmt.Errorf( + "ignoring the metrics recording for pipelineruns, failed to initialize the metrics recorder") + } + + ctx, err := tag.New( + context.Background(), + tag.Insert(r.namespace, namespace), + tag.Insert(r.repository, repository), + tag.Insert(r.status, status), + tag.Insert(r.reason, reason), + ) + if err != nil { + return err + } + + metrics.Record(ctx, prDurationCount.M(duration.Seconds())) + return nil +} diff --git a/pkg/reconciler/emit_metrics.go b/pkg/reconciler/emit_metrics.go index d01049f46..488e96f91 100644 --- a/pkg/reconciler/emit_metrics.go +++ b/pkg/reconciler/emit_metrics.go @@ -2,12 +2,27 @@ package reconciler import ( "fmt" + "time" "github.com/openshift-pipelines/pipelines-as-code/pkg/apis/pipelinesascode/keys" tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + corev1 "k8s.io/api/core/v1" + "knative.dev/pkg/apis" ) func (r *Reconciler) emitMetrics(pr *tektonv1.PipelineRun) error { + if err := r.countPipelineRun(pr); err != nil { + return err + } + + if err := r.calculatePRDuration(pr); err != nil { + return err + } + + return nil +} + +func (r *Reconciler) countPipelineRun(pr *tektonv1.PipelineRun) error { gitProvider := pr.GetAnnotations()[keys.GitProvider] eventType := pr.GetAnnotations()[keys.EventType] repository := pr.GetAnnotations()[keys.Repository] @@ -27,3 +42,26 @@ func (r *Reconciler) emitMetrics(pr *tektonv1.PipelineRun) error { return r.metrics.Count(gitProvider, eventType, pr.GetNamespace(), repository) } + +func (r *Reconciler) calculatePRDuration(pr *tektonv1.PipelineRun) error { + repository := pr.GetAnnotations()[keys.Repository] + duration := time.Duration(0) + if pr.Status.StartTime != nil { + duration = time.Since(pr.Status.StartTime.Time) + if pr.Status.CompletionTime != nil { + duration = pr.Status.CompletionTime.Sub(pr.Status.StartTime.Time) + } + } + + cond := pr.Status.GetCondition(apis.ConditionSucceeded) + status := "success" + if cond.Status == corev1.ConditionFalse { + status = "failed" + if cond.Reason == tektonv1.PipelineRunReasonCancelled.String() { + status = "cancelled" + } + } + reason := cond.Reason + + return r.metrics.CountPRDuration(pr.GetNamespace(), repository, status, reason, duration) +} diff --git a/pkg/reconciler/emit_metrics_test.go b/pkg/reconciler/emit_metrics_test.go index 6c48daae3..ec7f71e98 100644 --- a/pkg/reconciler/emit_metrics_test.go +++ b/pkg/reconciler/emit_metrics_test.go @@ -2,18 +2,26 @@ package reconciler import ( "testing" + "time" "github.com/openshift-pipelines/pipelines-as-code/pkg/apis/pipelinesascode/keys" "github.com/openshift-pipelines/pipelines-as-code/pkg/metrics" tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" "gotest.tools/v3/assert" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/pkg/apis" + duckv1 "knative.dev/pkg/apis/duck/v1" + "knative.dev/pkg/metrics/metricstest" + _ "knative.dev/pkg/metrics/testing" ) -func TestEmitMetrics(t *testing.T) { +// TestCountPipelineRun tests pipelinerun count metric. +func TestCountPipelineRun(t *testing.T) { tests := []struct { name string annotations map[string]string + tags map[string]string wantErr bool }{ { @@ -23,6 +31,10 @@ func TestEmitMetrics(t *testing.T) { keys.EventType: "pull_request", keys.InstallationID: "123", }, + tags: map[string]string{ + "provider": "github-app", + "event-type": "pull_request", + }, wantErr: false, }, { @@ -32,6 +44,10 @@ func TestEmitMetrics(t *testing.T) { keys.EventType: "pull_request", keys.InstallationID: "123", }, + tags: map[string]string{ + "provider": "github-enterprise-app", + "event-type": "pull_request", + }, wantErr: false, }, { @@ -40,6 +56,10 @@ func TestEmitMetrics(t *testing.T) { keys.GitProvider: "github", keys.EventType: "pull_request", }, + tags: map[string]string{ + "provider": "github-webhook", + "event-type": "pull_request", + }, wantErr: false, }, { @@ -48,6 +68,10 @@ func TestEmitMetrics(t *testing.T) { keys.GitProvider: "gitlab", keys.EventType: "push", }, + tags: map[string]string{ + "provider": "gitlab-webhook", + "event-type": "push", + }, wantErr: false, }, { @@ -62,6 +86,7 @@ func TestEmitMetrics(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + metricstest.Unregister("pipelines_as_code_pipelinerun_count") m, err := metrics.NewRecorder() assert.NilError(t, err) r := &Reconciler{ @@ -72,9 +97,168 @@ func TestEmitMetrics(t *testing.T) { Annotations: tt.annotations, }, } - if err = r.emitMetrics(pr); (err != nil) != tt.wantErr { - t.Errorf("emitMetrics() error = %v, wantErr %v", err != nil, tt.wantErr) + // checks that metric is unregistered successfully and there is no metric + // before emitting new pr count metric. + metricstest.AssertNoMetric(t, "pipelines_as_code_pipelinerun_count") + + if err = r.countPipelineRun(pr); (err != nil) != tt.wantErr { + t.Errorf("countPipelineRun() error = %v, wantErr %v", err != nil, tt.wantErr) + } + + if !tt.wantErr { + metricstest.CheckCountData(t, "pipelines_as_code_pipelinerun_count", tt.tags, 1) } }) } } + +// TestCalculatePipelineRunDuration tests pipelinerun duration metric. +func TestCalculatePipelineRunDuration(t *testing.T) { + startTime := metav1.Now() + tests := []struct { + name string + annotations map[string]string + conditionType apis.ConditionType + status corev1.ConditionStatus + reason string + completionTime metav1.Time + tags map[string]string + }{ + { + name: "pipelinerun succeeded", + annotations: map[string]string{ + keys.Repository: "pac-repo", + }, + conditionType: apis.ConditionSucceeded, + status: corev1.ConditionTrue, + reason: tektonv1.PipelineRunReasonSuccessful.String(), + completionTime: metav1.NewTime(startTime.Time.Add(time.Minute)), + tags: map[string]string{ + "namespace": "pac-ns", + "reason": tektonv1.PipelineRunReasonSuccessful.String(), + "repository": "pac-repo", + "status": "success", + }, + }, + { + name: "pipelinerun completed", + annotations: map[string]string{ + keys.Repository: "pac-repo", + }, + conditionType: apis.ConditionSucceeded, + status: corev1.ConditionTrue, + reason: tektonv1.PipelineRunReasonCompleted.String(), + completionTime: metav1.NewTime(startTime.Time.Add(time.Minute)), + tags: map[string]string{ + "namespace": "pac-ns", + "reason": tektonv1.PipelineRunReasonCompleted.String(), + "repository": "pac-repo", + "status": "success", + }, + }, + { + name: "pipelinerun failed", + annotations: map[string]string{ + keys.Repository: "pac-repo", + }, + conditionType: apis.ConditionSucceeded, + status: corev1.ConditionFalse, + reason: tektonv1.PipelineRunReasonFailed.String(), + completionTime: metav1.NewTime(startTime.Time.Add(2 * time.Minute)), + tags: map[string]string{ + "namespace": "pac-ns", + "reason": tektonv1.PipelineRunReasonFailed.String(), + "repository": "pac-repo", + "status": "failed", + }, + }, + { + name: "pipelinerun cancelled", + annotations: map[string]string{ + keys.Repository: "pac-repo", + }, + conditionType: apis.ConditionSucceeded, + status: corev1.ConditionFalse, + reason: tektonv1.PipelineRunReasonCancelled.String(), + completionTime: metav1.NewTime(startTime.Time.Add(2 * time.Second)), + tags: map[string]string{ + "namespace": "pac-ns", + "reason": tektonv1.PipelineRunReasonCancelled.String(), + "repository": "pac-repo", + "status": "cancelled", + }, + }, + { + name: "pipelinerun timed out", + annotations: map[string]string{ + keys.Repository: "pac-repo", + }, + conditionType: apis.ConditionSucceeded, + status: corev1.ConditionFalse, + reason: tektonv1.PipelineRunReasonTimedOut.String(), + completionTime: metav1.NewTime(startTime.Time.Add(10 * time.Minute)), + tags: map[string]string{ + "namespace": "pac-ns", + "reason": tektonv1.PipelineRunReasonTimedOut.String(), + "repository": "pac-repo", + "status": "failed", + }, + }, + { + name: "pipelinerun failed due to couldn't get pipeline", + annotations: map[string]string{ + keys.Repository: "pac-repo", + }, + conditionType: apis.ConditionSucceeded, + status: corev1.ConditionFalse, + reason: tektonv1.PipelineRunReasonCouldntGetPipeline.String(), + completionTime: metav1.NewTime(startTime.Time.Add(time.Second)), + tags: map[string]string{ + "namespace": "pac-ns", + "reason": tektonv1.PipelineRunReasonCouldntGetPipeline.String(), + "repository": "pac-repo", + "status": "failed", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + metricstest.Unregister("pipelines_as_code_pipelinerun_duration_seconds_sum") + m, err := metrics.NewRecorder() + assert.NilError(t, err) + r := &Reconciler{ + metrics: m, + } + pr := &tektonv1.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "pac-ns", + Annotations: tt.annotations, + }, + Status: tektonv1.PipelineRunStatus{ + Status: duckv1.Status{Conditions: []apis.Condition{ + { + Type: tt.conditionType, + Status: tt.status, + Reason: tt.reason, + }, + }}, + PipelineRunStatusFields: tektonv1.PipelineRunStatusFields{ + StartTime: &startTime, + CompletionTime: &tt.completionTime, + }, + }, + } + // checks that metric is unregistered successfully and there is no metric + // before emitting new pr duration metric. + metricstest.AssertNoMetric(t, "pipelines_as_code_pipelinerun_duration_seconds_sum") + + if err = r.calculatePRDuration(pr); err != nil { + t.Errorf("calculatePRDuration() error = %v", err) + } + + duration := tt.completionTime.Sub(startTime.Time) + metricstest.CheckSumData(t, "pipelines_as_code_pipelinerun_duration_seconds_sum", tt.tags, duration.Seconds()) + }) + } +} diff --git a/vendor/knative.dev/pkg/metrics/metricstest/metricstest.go b/vendor/knative.dev/pkg/metrics/metricstest/metricstest.go new file mode 100644 index 000000000..106c86452 --- /dev/null +++ b/vendor/knative.dev/pkg/metrics/metricstest/metricstest.go @@ -0,0 +1,261 @@ +/* +Copyright 2019 The Knative 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 metricstest + +import ( + "fmt" + "reflect" + + "go.opencensus.io/metric/metricproducer" + "go.opencensus.io/stats/view" +) + +type ti interface { + Helper() + Error(args ...interface{}) +} + +// CheckStatsReported checks that there is a view registered with the given name for each string in names, +// and that each view has at least one record. +func CheckStatsReported(t ti, names ...string) { + t.Helper() + for _, name := range names { + d, err := readRowsFromAllMeters(name) + if err != nil { + t.Error("For metric, Reporter.Report() error", "metric", name, "error", err) + } + if len(d) < 1 { + t.Error("For metric, no data reported when data was expected, view data is empty.", "metric", name) + } + } +} + +// CheckStatsNotReported checks that there are no records for any views that a name matching a string in names. +// Names that do not match registered views are considered not reported. +func CheckStatsNotReported(t ti, names ...string) { + t.Helper() + for _, name := range names { + d, err := readRowsFromAllMeters(name) + // err == nil means a valid stat exists matching "name" + // len(d) > 0 means a component recorded metrics for that stat + if err == nil && len(d) > 0 { + t.Error("For metric, unexpected data reported when no data was expected.", "metric", name, "Reporter len(d)", len(d)) + } + } +} + +// CheckCountData checks the view with a name matching string name to verify that the CountData stats +// reported are tagged with the tags in wantTags and that wantValue matches reported count. +func CheckCountData(t ti, name string, wantTags map[string]string, wantValue int64) { + t.Helper() + row, err := checkExactlyOneRow(t, name) + if err != nil { + t.Error(err) + return + } + checkRowTags(t, row, name, wantTags) + + if s, ok := row.Data.(*view.CountData); !ok { + t.Error("want CountData", "metric", name, "got", reflect.TypeOf(row.Data)) + } else if s.Value != wantValue { + t.Error("Wrong value", "metric", name, "value", s.Value, "want", wantValue) + } +} + +// CheckDistributionData checks the view with a name matching string name to verify that the DistributionData stats reported +// are tagged with the tags in wantTags and that expectedCount number of records were reported. +// It also checks that expectedMin and expectedMax match the minimum and maximum reported values, respectively. +func CheckDistributionData(t ti, name string, wantTags map[string]string, expectedCount int64, expectedMin float64, expectedMax float64) { + t.Helper() + row, err := checkExactlyOneRow(t, name) + if err != nil { + t.Error(err) + return + } + checkRowTags(t, row, name, wantTags) + + if s, ok := row.Data.(*view.DistributionData); !ok { + t.Error("want DistributionData", "metric", name, "got", reflect.TypeOf(row.Data)) + } else { + if s.Count != expectedCount { + t.Error("reporter count wrong", "metric", name, "got", s.Count, "want", expectedCount) + } + if s.Min != expectedMin { + t.Error("reporter min wrong", "metric", name, "got", s.Min, "want", expectedMin) + } + if s.Max != expectedMax { + t.Error("reporter max wrong", "metric", name, "got", s.Max, "want", expectedMax) + } + } +} + +// CheckDistributionCount checks the view with a name matching string name to verify that the DistributionData stats reported +// are tagged with the tags in wantTags and that expectedCount number of records were reported. +func CheckDistributionCount(t ti, name string, wantTags map[string]string, expectedCount int64) { + t.Helper() + row, err := checkExactlyOneRow(t, name) + if err != nil { + t.Error(err) + return + } + checkRowTags(t, row, name, wantTags) + + if s, ok := row.Data.(*view.DistributionData); !ok { + t.Error("want DistributionData", "metric", name, "got", reflect.TypeOf(row.Data)) + } else if s.Count != expectedCount { + t.Error("reporter count wrong", "metric", name, "got", s.Count, "want", expectedCount) + } +} + +// GetLastValueData returns the last value for the given metric, verifying tags. +func GetLastValueData(t ti, name string, tags map[string]string) float64 { + t.Helper() + return GetLastValueDataWithMeter(t, name, tags, nil) +} + +// GetLastValueDataWithMeter returns the last value of the given metric using meter, verifying tags. +func GetLastValueDataWithMeter(t ti, name string, tags map[string]string, meter view.Meter) float64 { + t.Helper() + if row := lastRow(t, name, meter); row != nil { + checkRowTags(t, row, name, tags) + + s, ok := row.Data.(*view.LastValueData) + if !ok { + t.Error("want LastValueData", "metric", name, "got", reflect.TypeOf(row.Data)) + } + return s.Value + } + return 0 +} + +// CheckLastValueData checks the view with a name matching string name to verify that the LastValueData stats +// reported are tagged with the tags in wantTags and that wantValue matches reported last value. +func CheckLastValueData(t ti, name string, wantTags map[string]string, wantValue float64) { + t.Helper() + CheckLastValueDataWithMeter(t, name, wantTags, wantValue, nil) +} + +// CheckLastValueDataWithMeter checks the view with a name matching the string name in the +// specified Meter (resource-specific view) to verify that the LastValueData stats are tagged with +// the tags in wantTags and that wantValue matches the last reported value. +func CheckLastValueDataWithMeter(t ti, name string, wantTags map[string]string, wantValue float64, meter view.Meter) { + t.Helper() + if v := GetLastValueDataWithMeter(t, name, wantTags, meter); v != wantValue { + t.Error("Reporter.Report() wrong value", "metric", name, "got", v, "want", wantValue) + } +} + +// CheckSumData checks the view with a name matching string name to verify that the SumData stats +// reported are tagged with the tags in wantTags and that wantValue matches the reported sum. +func CheckSumData(t ti, name string, wantTags map[string]string, wantValue float64) { + t.Helper() + row, err := checkExactlyOneRow(t, name) + if err != nil { + t.Error(err) + return + } + checkRowTags(t, row, name, wantTags) + + if s, ok := row.Data.(*view.SumData); !ok { + t.Error("Wrong type", "metric", name, "got", reflect.TypeOf(row.Data), "want", "SumData") + } else if s.Value != wantValue { + t.Error("Wrong sumdata", "metric", name, "got", s.Value, "want", wantValue) + } +} + +// Unregister unregisters the metrics that were registered. +// This is useful for testing since golang execute test iterations within the same process and +// opencensus views maintain global state. At the beginning of each test, tests should +// unregister for all metrics and then re-register for the same metrics. This effectively clears +// out any existing data and avoids a panic due to re-registering a metric. +// +// In normal process shutdown, metrics do not need to be unregistered. +func Unregister(names ...string) { + for _, producer := range metricproducer.GlobalManager().GetAll() { + meter := producer.(view.Meter) + for _, n := range names { + if v := meter.Find(n); v != nil { + meter.Unregister(v) + } + } + } +} + +func lastRow(t ti, name string, meter view.Meter) *view.Row { + t.Helper() + var d []*view.Row + var err error + if meter != nil { + d, err = meter.RetrieveData(name) + } else { + d, err = readRowsFromAllMeters(name) + } + if err != nil { + t.Error("Reporter.Report() error", "metric", name, "error", err) + return nil + } + if len(d) < 1 { + t.Error("Reporter.Report() wrong length", "metric", name, "got", len(d), "want at least", 1) + return nil + } + + return d[len(d)-1] +} + +func checkExactlyOneRow(t ti, name string) (*view.Row, error) { + rows, err := readRowsFromAllMeters(name) + if err != nil || len(rows) == 0 { + return nil, fmt.Errorf("could not find row for %q", name) + } + if len(rows) > 1 { + return nil, fmt.Errorf("expected 1 row for metric %q got %d", name, len(rows)) + } + return rows[0], nil +} + +func readRowsFromAllMeters(name string) ([]*view.Row, error) { + // view.Meter implements (and is exposed by) metricproducer.GetAll. Since + // this is a test, reach around and cast these to view.Meter. + var rows []*view.Row + for _, producer := range metricproducer.GlobalManager().GetAll() { + meter := producer.(view.Meter) + d, err := meter.RetrieveData(name) + if err != nil || len(d) == 0 { + continue + } + if rows != nil { + return nil, fmt.Errorf("got metrics for the same name from different meters: %+v, %+v", rows, d) + } + rows = d + } + return rows, nil +} + +func checkRowTags(t ti, row *view.Row, name string, wantTags map[string]string) { + t.Helper() + if wantlen, gotlen := len(wantTags), len(row.Tags); gotlen != wantlen { + t.Error("Reporter got wrong number of tags", "metric", name, "got", gotlen, "want", wantlen) + } + for _, got := range row.Tags { + n := got.Key.Name() + if want, ok := wantTags[n]; !ok { + t.Error("Reporter got an extra tag", "metric", name, "gotName", n, "gotValue", got.Value) + } else if got.Value != want { + t.Error("Reporter expected a different tag value for key", "metric", name, "key", n, "got", got.Value, "want", want) + } + } +} diff --git a/vendor/knative.dev/pkg/metrics/metricstest/resource_metrics.go b/vendor/knative.dev/pkg/metrics/metricstest/resource_metrics.go new file mode 100644 index 000000000..619d53684 --- /dev/null +++ b/vendor/knative.dev/pkg/metrics/metricstest/resource_metrics.go @@ -0,0 +1,351 @@ +/* +Copyright 2020 The Knative 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 metricstest simplifies some of the common boilerplate around testing +// metrics exports. It should work with or without the code in metrics, but this +// code particularly knows how to deal with metrics which are exported for +// multiple Resources in the same process. +package metricstest + +import ( + "fmt" + "sort" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "go.opencensus.io/metric/metricdata" + "go.opencensus.io/metric/metricproducer" + "go.opencensus.io/resource" + "go.opencensus.io/stats/view" +) + +// Value provides a simplified implementation of a metric Value suitable for +// easy testing. +type Value struct { + Tags map[string]string + // union interface, only one of these will be set + Int64 *int64 + Float64 *float64 + Distribution *metricdata.Distribution + // VerifyDistributionCountOnly makes Equal compare the Distribution with the + // field Count only, and ignore all other fields of Distribution. + // This is ignored when the value is not a Distribution. + VerifyDistributionCountOnly bool +} + +// Metric provides a simplified (for testing) implementation of a metric report +// for a given metric name in a given Resource. +type Metric struct { + // Name is the exported name of the metric, probably from the View's name. + Name string + // Unit is the units of measure of the metric. This is only checked for + // equality if Unit is non-empty or VerifyMetadata is true on both Metrics. + Unit metricdata.Unit + // Type is the type of measurement represented by the metric. This is only + // checked for equality if VerifyMetadata is true on both Metrics. + Type metricdata.Type + + // Resource is the reported Resource (if any) for this metric. This is only + // checked for equality if Resource is non-nil or VerifyResource is true on + // both Metrics. + Resource *resource.Resource + + // Values contains the values recorded for different Key=Value Tag + // combinations. Value is checked for equality if present. + Values []Value + + // Equality testing/validation settings on the Metric. These are used to + // allow simple construction and usage with github.com/google/go-cmp/cmp + + // VerifyMetadata makes Equal compare Unit and Type if it is true on both + // Metrics. + VerifyMetadata bool + // VerifyResource makes Equal compare Resource if it is true on Metrics with + // nil Resource. Metrics with non-nil Resource are always compared. + VerifyResource bool +} + +// NewMetric creates a Metric from a metricdata.Metric, which is designed for +// compact wire representation. +func NewMetric(metric *metricdata.Metric) Metric { + value := Metric{ + Name: metric.Descriptor.Name, + Unit: metric.Descriptor.Unit, + Type: metric.Descriptor.Type, + Resource: metric.Resource, + + VerifyMetadata: true, + VerifyResource: true, + + Values: make([]Value, 0, len(metric.TimeSeries)), + } + + for _, ts := range metric.TimeSeries { + tags := make(map[string]string, len(metric.Descriptor.LabelKeys)) + for i, k := range metric.Descriptor.LabelKeys { + if ts.LabelValues[i].Present { + tags[k.Key] = ts.LabelValues[i].Value + } + } + v := Value{Tags: tags} + ts.Points[0].ReadValue(&v) + value.Values = append(value.Values, v) + } + + return value +} + +// EnsureRecorded makes sure that all stats metrics are actually flushed and recorded. +func EnsureRecorded() { + // stats.Record queues the actual record to a channel to be accounted for by + // a background goroutine (nonblocking). Call a method which does a + // round-trip to that goroutine to ensure that records have been flushed. + for _, producer := range metricproducer.GlobalManager().GetAll() { + if meter, ok := producer.(view.Meter); ok { + meter.Find("nonexistent") + } + } +} + +// GetMetric returns all values for the named metric. +func GetMetric(name string) []Metric { + producers := metricproducer.GlobalManager().GetAll() + retval := make([]Metric, 0, len(producers)) + for _, p := range producers { + for _, m := range p.Read() { + if m.Descriptor.Name == name && len(m.TimeSeries) > 0 { + retval = append(retval, NewMetric(m)) + } + } + } + return retval +} + +// GetOneMetric is like GetMetric, but it panics if more than a single Metric is +// found. +func GetOneMetric(name string) Metric { + m := GetMetric(name) + if len(m) != 1 { + panic(fmt.Sprint("Got wrong number of metrics:", m)) + } + return m[0] +} + +// IntMetric creates an Int64 metric. +func IntMetric(name string, value int64, tags map[string]string) Metric { + return Metric{ + Name: name, + Values: []Value{{Int64: &value, Tags: tags}}, + } +} + +// FloatMetric creates a Float64 metric +func FloatMetric(name string, value float64, tags map[string]string) Metric { + return Metric{ + Name: name, + Values: []Value{{Float64: &value, Tags: tags}}, + } +} + +// DistributionCountOnlyMetric creates a distribution metric for test, and verifying only the count. +func DistributionCountOnlyMetric(name string, count int64, tags map[string]string) Metric { + return Metric{ + Name: name, + Values: []Value{{ + Distribution: &metricdata.Distribution{Count: count}, + Tags: tags, + VerifyDistributionCountOnly: true, + }}, + } +} + +// WithResource sets the resource of the metric. +func (m Metric) WithResource(r *resource.Resource) Metric { + m.Resource = r + return m +} + +// AssertMetric verifies that the metrics have the specified values. Note that +// this method will spuriously fail if there are multiple metrics with the same +// name on different Meters. Calls EnsureRecorded internally before fetching the +// batch of metrics. +func AssertMetric(t *testing.T, values ...Metric) { + t.Helper() + EnsureRecorded() + for _, v := range values { + if diff := cmp.Diff(v, GetOneMetric(v.Name)); diff != "" { + t.Error("Wrong metric (-want +got):", diff) + } + } +} + +// AssertMetricExists verifies that at least one metric values has been reported for +// each of metric names. +// Calls EnsureRecorded internally before fetching the batch of metrics. +func AssertMetricExists(t *testing.T, names ...string) { + metrics := make([]Metric, 0, len(names)) + for _, n := range names { + metrics = append(metrics, Metric{Name: n}) + } + AssertMetric(t, metrics...) +} + +// AssertNoMetric verifies that no metrics have been reported for any of the +// metric names. +// Calls EnsureRecorded internally before fetching the batch of metrics. +func AssertNoMetric(t *testing.T, names ...string) { + t.Helper() + EnsureRecorded() + for _, name := range names { + if m := GetMetric(name); len(m) != 0 { + t.Error("Found unexpected data for:", m) + } + } +} + +// VisitFloat64Value implements metricdata.ValueVisitor. +func (v *Value) VisitFloat64Value(f float64) { + v.Float64 = &f + v.Int64 = nil + v.Distribution = nil +} + +// VisitInt64Value implements metricdata.ValueVisitor. +func (v *Value) VisitInt64Value(i int64) { + v.Int64 = &i + v.Float64 = nil + v.Distribution = nil +} + +// VisitDistributionValue implements metricdata.ValueVisitor. +func (v *Value) VisitDistributionValue(d *metricdata.Distribution) { + v.Distribution = d + v.Int64 = nil + v.Float64 = nil +} + +// VisitSummaryValue implements metricdata.ValueVisitor. +func (v *Value) VisitSummaryValue(*metricdata.Summary) { + panic("Attempted to fetch summary value, which we never use!") +} + +// Equal provides a contract for use with github.com/google/go-cmp/cmp. Due to +// the reflection in cmp, it only works if the type of the two arguments to cmp +// are the same. +func (m Metric) Equal(other Metric) bool { + if m.Name != other.Name { + return false + } + if (m.Unit != "" || m.VerifyMetadata) && (other.Unit != "" || other.VerifyMetadata) { + if m.Unit != other.Unit { + return false + } + } + if m.VerifyMetadata && other.VerifyMetadata { + if m.Type != other.Type { + return false + } + } + + if (m.Resource != nil || m.VerifyResource) && (other.Resource != nil || other.VerifyResource) { + if !cmp.Equal(m.Resource, other.Resource) { + return false + } + } + + if len(m.Values) > 0 && len(other.Values) > 0 { + if len(m.Values) != len(other.Values) { + return false + } + myValues := make(map[string]Value, len(m.Values)) + for _, v := range m.Values { + myValues[tagsToString(v.Tags)] = v + } + for _, v := range other.Values { + myV, ok := myValues[tagsToString(v.Tags)] + if !ok || !myV.Equal(v) { + return false + } + } + } + + return true +} + +// Equal provides a contract for github.com/google/go-cmp/cmp. It compares two +// values, including deep comparison of Distributions. (Exemplars are +// intentional not included in the comparison, but other fields are considered). +func (v Value) Equal(other Value) bool { + if len(v.Tags) != len(other.Tags) { + return false + } + for k, v := range v.Tags { + if v != other.Tags[k] { + return false + } + } + if v.Int64 != nil { + return other.Int64 != nil && *v.Int64 == *other.Int64 + } + if v.Float64 != nil { + return other.Float64 != nil && *v.Float64 == *other.Float64 + } + + if v.Distribution != nil { + if other.Distribution == nil { + return false + } + if v.Distribution.Count != other.Distribution.Count { + return false + } + if v.VerifyDistributionCountOnly || other.VerifyDistributionCountOnly { + return true + } + if v.Distribution.Sum != other.Distribution.Sum { + return false + } + if v.Distribution.SumOfSquaredDeviation != other.Distribution.SumOfSquaredDeviation { + return false + } + if v.Distribution.BucketOptions != nil { + if other.Distribution.BucketOptions == nil { + return false + } + for i, bo := range v.Distribution.BucketOptions.Bounds { + if bo != other.Distribution.BucketOptions.Bounds[i] { + return false + } + } + } + for i, b := range v.Distribution.Buckets { + if b.Count != other.Distribution.Buckets[i].Count { + return false + } + } + } + + return true +} + +func tagsToString(tags map[string]string) string { + pairs := make([]string, 0, len(tags)) + for k, v := range tags { + pairs = append(pairs, fmt.Sprintf("%s=%s", k, v)) + } + sort.Strings(pairs) + return strings.Join(pairs, ",") +} diff --git a/vendor/knative.dev/pkg/metrics/testing/config.go b/vendor/knative.dev/pkg/metrics/testing/config.go new file mode 100644 index 000000000..2df88b6e0 --- /dev/null +++ b/vendor/knative.dev/pkg/metrics/testing/config.go @@ -0,0 +1,28 @@ +/* +Copyright 2019 The Knative 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 + + https://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 testing + +import ( + "os" + + "knative.dev/pkg/metrics" +) + +func init() { + os.Setenv(metrics.DomainEnv, "knative.dev/testing") + metrics.InitForTesting() +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 88e59e76e..60d62b9d9 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1176,6 +1176,8 @@ knative.dev/pkg/logging/logkey knative.dev/pkg/logging/testing knative.dev/pkg/metrics knative.dev/pkg/metrics/metricskey +knative.dev/pkg/metrics/metricstest +knative.dev/pkg/metrics/testing knative.dev/pkg/network knative.dev/pkg/network/handlers knative.dev/pkg/profiling