diff --git a/exporters/stdout/stdoutmetric/config.go b/exporters/stdout/stdoutmetric/config.go new file mode 100644 index 00000000000..a90bdc7b811 --- /dev/null +++ b/exporters/stdout/stdoutmetric/config.go @@ -0,0 +1,78 @@ +// Copyright The OpenTelemetry 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 stdoutmetric // import "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric" + +import ( + "io" + "os" +) + +var ( + defaultWriter = os.Stdout + defaultPrettyPrint = false +) + +// config contains options for the STDOUT exporter. +type config struct { + // Writer is the destination. If not set, os.Stdout is used. + Writer io.Writer + + // PrettyPrint will encode the output into readable JSON. Default is + // false. + PrettyPrint bool +} + +// newConfig creates a validated Config configured with options. +func newConfig(options ...Option) (config, error) { + cfg := config{ + Writer: defaultWriter, + PrettyPrint: defaultPrettyPrint, + } + for _, opt := range options { + cfg = opt.apply(cfg) + } + return cfg, nil +} + +// Option sets the value of an option for a Config. +type Option interface { + apply(config) config +} + +// WithWriter sets the export stream destination. +func WithWriter(w io.Writer) Option { + return writerOption{w} +} + +type writerOption struct { + W io.Writer +} + +func (o writerOption) apply(cfg config) config { + cfg.Writer = o.W + return cfg +} + +// WithPrettyPrint sets the export stream format to use JSON. +func WithPrettyPrint() Option { + return prettyPrintOption(true) +} + +type prettyPrintOption bool + +func (o prettyPrintOption) apply(cfg config) config { + cfg.PrettyPrint = bool(o) + return cfg +} diff --git a/exporters/stdout/stdoutmetric/example_test.go b/exporters/stdout/stdoutmetric/example_test.go new file mode 100644 index 00000000000..6c2f0a8982f --- /dev/null +++ b/exporters/stdout/stdoutmetric/example_test.go @@ -0,0 +1,275 @@ +// Copyright The OpenTelemetry 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. + +//go:build go1.18 +// +build go1.18 + +package stdoutmetric + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric/unit" + "go.opentelemetry.io/otel/sdk/instrumentation" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + "go.opentelemetry.io/otel/sdk/resource" +) + +func TestExporterExport(t *testing.T) { + now := time.Unix(1558033527, 0) + + exampleData := metricdata.ResourceMetrics{ + Resource: resource.NewWithAttributes("example", attribute.String("resource-foo", "bar")), + ScopeMetrics: []metricdata.ScopeMetrics{ + { + Scope: instrumentation.Scope{ + Name: "scope1", + Version: "v1", + SchemaURL: "anotherExample", + }, + Metrics: []metricdata.Metrics{ + { + Name: "gauge", + Description: "Gauge's description", + Unit: unit.Dimensionless, + Data: metricdata.Gauge[int64]{ + DataPoints: []metricdata.DataPoint[int64]{ + { + Attributes: attribute.NewSet(), + StartTime: now.Add(-1 * time.Minute), + Time: now, + Value: 3, + }, + }, + }, + }, + { + Name: "sum", + Description: "Sum's description", + Unit: unit.Bytes, + Data: metricdata.Sum[float64]{ + Temporality: metricdata.DeltaTemporality, + IsMonotonic: false, + DataPoints: []metricdata.DataPoint[float64]{ + { + Attributes: attribute.NewSet(), + StartTime: now.Add(-1 * time.Minute), + Time: now, + Value: 3, + }, + { + Attributes: attribute.NewSet(attribute.String("foo", "bar")), + StartTime: now.Add(-1 * time.Minute), + Time: now, + Value: 3, + }, + }, + }, + }, + { + Name: "histogram", + Description: "Histogram's description", + Unit: unit.Bytes, + Data: metricdata.Histogram{ + Temporality: metricdata.CumulativeTemporality, + DataPoints: []metricdata.HistogramDataPoint{ + { + Attributes: attribute.NewSet(), + StartTime: now.Add(-1 * time.Minute), + Time: now, + Count: 6, + Bounds: []float64{ + 1.0, + 5.0, + }, + BucketCounts: []uint64{ + 1, 2, 3, + }, + Min: nil, + Max: nil, + Sum: 200, + }, + }, + }, + }, + }, + }, + { + Scope: instrumentation.Scope{ + Name: "Scope2", + Version: "v2", + SchemaURL: "aDifferentExample", + }, + Metrics: []metricdata.Metrics{ + { + Name: "gauge", + Description: "Gauge's description", + Unit: unit.Dimensionless, + Data: metricdata.Gauge[int64]{ + DataPoints: []metricdata.DataPoint[int64]{ + { + Attributes: attribute.NewSet(), + StartTime: now.Add(-1 * time.Minute), + Time: now, + Value: 3, + }, + }, + }, + }, + }, + }, + }, + } + + buf := &bytes.Buffer{} + exp, err := New( + WithWriter(buf), + WithPrettyPrint(), + ) + require.NoError(t, err) + + err = exp.Export(context.Background(), exampleData) + + require.NoError(t, err) + assert.Equal(t, expectedOutput, buf.String()) +} + +var expectedOutput = `{ + "Resource": [ + { + "Key": "resource-foo", + "Value": { + "Type": "STRING", + "Value": "bar" + } + } + ], + "ScopeMetrics": [ + { + "Scope": { + "Name": "scope1", + "Version": "v1", + "SchemaURL": "anotherExample" + }, + "Metrics": [ + { + "Name": "gauge", + "Description": "Gauge's description", + "Unit": "1", + "Data": { + "DataPoints": [ + { + "Attributes": [], + "StartTime": "2019-05-16T19:04:27Z", + "Time": "2019-05-16T19:05:27Z", + "Value": 3 + } + ] + } + }, + { + "Name": "sum", + "Description": "Sum's description", + "Unit": "By", + "Data": { + "DataPoints": [ + { + "Attributes": [], + "StartTime": "2019-05-16T19:04:27Z", + "Time": "2019-05-16T19:05:27Z", + "Value": 3 + }, + { + "Attributes": [ + { + "Key": "foo", + "Value": { + "Type": "STRING", + "Value": "bar" + } + } + ], + "StartTime": "2019-05-16T19:04:27Z", + "Time": "2019-05-16T19:05:27Z", + "Value": 3 + } + ], + "Temporality": 2, + "IsMonotonic": false + } + }, + { + "Name": "histogram", + "Description": "Histogram's description", + "Unit": "By", + "Data": { + "DataPoints": [ + { + "Attributes": [], + "StartTime": "2019-05-16T19:04:27Z", + "Time": "2019-05-16T19:05:27Z", + "Count": 6, + "Bounds": [ + 1, + 5 + ], + "BucketCounts": [ + 1, + 2, + 3 + ], + "Min": null, + "Max": null, + "Sum": 200 + } + ], + "Temporality": 1 + } + } + ] + }, + { + "Scope": { + "Name": "Scope2", + "Version": "v2", + "SchemaURL": "aDifferentExample" + }, + "Metrics": [ + { + "Name": "gauge", + "Description": "Gauge's description", + "Unit": "1", + "Data": { + "DataPoints": [ + { + "Attributes": [], + "StartTime": "2019-05-16T19:04:27Z", + "Time": "2019-05-16T19:05:27Z", + "Value": 3 + } + ] + } + } + ] + } + ] +} +` \ No newline at end of file diff --git a/exporters/stdout/stdoutmetric/exporter.go b/exporters/stdout/stdoutmetric/exporter.go index d44e977e6d8..7e20959f2dd 100644 --- a/exporters/stdout/stdoutmetric/exporter.go +++ b/exporters/stdout/stdoutmetric/exporter.go @@ -20,22 +20,30 @@ package stdoutmetric // import "go.opentelemetry.io/otel/exporters/stdout/stdout import ( "context" "encoding/json" - "os" "sync" "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/metric/metricdata" ) -func New() (*Exporter, error) { +// New creates an Exporter with the passed options. +func New(options ...Option) (*Exporter, error) { + cfg, err := newConfig(options...) + if err != nil { + return nil, err + } + + enc := json.NewEncoder(cfg.Writer) + if cfg.PrettyPrint { + enc.SetIndent("", "\t") + } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", "\t") return &Exporter{ encoder: enc, }, nil } +// Exporter is an implementation of metric.Exporter that writes ResourceMetrics to stdout. type Exporter struct { encoder *json.Encoder encoderMu sync.Mutex diff --git a/exporters/stdout/stdoutmetric/exporter_test.go b/exporters/stdout/stdoutmetric/exporter_test.go index 9d6eafc4cff..43128899ecd 100644 --- a/exporters/stdout/stdoutmetric/exporter_test.go +++ b/exporters/stdout/stdoutmetric/exporter_test.go @@ -15,63 +15,54 @@ //go:build go1.18 // +build go1.18 -package stdoutmetric +package stdoutmetric // import "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric" import ( "context" + "testing" "time" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/sdk/instrumentation" - "go.opentelemetry.io/otel/sdk/metric/metricdata" - "go.opentelemetry.io/otel/sdk/resource" + "github.com/stretchr/testify/assert" ) -func ExampleExporter_Export() { - exampleData := metricdata.ResourceMetrics{ - Resource: resource.NewWithAttributes("example", attribute.String("resource-foo", "bar")), - ScopeMetrics: []metricdata.ScopeMetrics{ - { - Scope: instrumentation.Scope{ - Name: "Scope1", - Version: "v1", - SchemaURL: "anotherExample", - }, - Metrics: []metricdata.Metrics{ - { - Name: "", - Description: "", - Unit: "", - Data: metricdata.Gauge[int64]{ - DataPoints: []metricdata.DataPoint[int64]{ - { - Attributes: attribute.NewSet(), - StartTime: time.Now().Add(-1 * time.Minute), - Time: time.Now(), - Value: 3, - }, - }, - }, - }, - {}, - {}, - }, - }, - { - Scope: instrumentation.Scope{ - Name: "Scope2", - Version: "v2", - SchemaURL: "aDifferentExample", - }, - Metrics: []metricdata.Metrics{ - {}, - {}, - {}, - }, - }, - }, +func TestExporterShutdownHonorsTimeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + + e, err := New() + if err != nil { + t.Fatalf("failed to create exporter: %v", err) + } + + innerCtx, innerCancel := context.WithTimeout(ctx, time.Nanosecond) + defer innerCancel() + <-innerCtx.Done() + err = e.Shutdown(innerCtx) + assert.ErrorIs(t, err, context.DeadlineExceeded) +} + +func TestExporterShutdownHonorsCancel(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + + e, err := New() + if err != nil { + t.Fatalf("failed to create exporter: %v", err) + } + + innerCtx, innerCancel := context.WithCancel(ctx) + innerCancel() + err = e.Shutdown(innerCtx) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestExporterShutdownNoError(t *testing.T) { + e, err := New() + if err != nil { + t.Fatalf("failed to create exporter: %v", err) + } + + if err := e.Shutdown(context.Background()); err != nil { + t.Errorf("shutdown errored: expected nil, got %v", err) } - exp, _ := New() - exp.Export(context.Background(), exampleData) - // Output: Hello } diff --git a/exporters/stdout/stdoutmetric/go.mod b/exporters/stdout/stdoutmetric/go.mod index c2ca17a95f1..2150c6fa626 100644 --- a/exporters/stdout/stdoutmetric/go.mod +++ b/exporters/stdout/stdoutmetric/go.mod @@ -3,17 +3,21 @@ module go.opentelemetry.io/otel/exporters/stdout/stdoutmetric go 1.18 require ( + github.com/stretchr/testify v1.7.1 + go.opentelemetry.io/otel v1.8.0 + go.opentelemetry.io/otel/metric v0.31.0 go.opentelemetry.io/otel/sdk v1.8.0 go.opentelemetry.io/otel/sdk/metric v0.31.0 ) require ( + github.com/davecgh/go-spew v1.1.0 // indirect github.com/go-logr/logr v1.2.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - go.opentelemetry.io/otel v1.8.0 // indirect - go.opentelemetry.io/otel/metric v0.31.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect go.opentelemetry.io/otel/trace v1.8.0 // indirect golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect ) replace go.opentelemetry.io/otel/trace => ../../../trace diff --git a/exporters/stdout/stdoutmetric/go.sum b/exporters/stdout/stdoutmetric/go.sum index 2af466654a5..2e2aed63d24 100644 --- a/exporters/stdout/stdoutmetric/go.sum +++ b/exporters/stdout/stdoutmetric/go.sum @@ -1,4 +1,5 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -6,7 +7,13 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7 h1:iGu644GcxtEcrInvDsQRCwJjtCIOlT2V7IRt6ah2Whw= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=