From 273e4818290a930d8a7fd5155c387412596cf3a5 Mon Sep 17 00:00:00 2001 From: Aleksandr Razumov Date: Sun, 26 Jan 2025 22:39:24 +0300 Subject: [PATCH] feat(autometric): init Copy implementation from oteldb by tdakkota --- README.md | 1 + autometric/autometric.go | 174 ++++++++++++++++++++++++++++++++++ autometric/autometric_test.go | 168 ++++++++++++++++++++++++++++++++ autometric/strcase.go | 71 ++++++++++++++ autometric/strcase_test.go | 33 +++++++ 5 files changed, 447 insertions(+) create mode 100644 autometric/autometric.go create mode 100644 autometric/autometric_test.go create mode 100644 autometric/strcase.go create mode 100644 autometric/strcase_test.go diff --git a/README.md b/README.md index a74ed7f..583fb80 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Implements automatic setup of observability and daemonization based on environme | `zctx` | context.Context and tracing support for zap | | `gold` | Golden files in tests | | `app` | Automatic setup observability and run daemon | +| `autometric` | Reflect-based OpenTelemetry metric initializer | ## Environment variables diff --git a/autometric/autometric.go b/autometric/autometric.go new file mode 100644 index 0000000..a6bd2bc --- /dev/null +++ b/autometric/autometric.go @@ -0,0 +1,174 @@ +// Package autometric contains a simple reflect-based OpenTelemetry metric initializer. +package autometric + +import ( + "reflect" + "strconv" + "strings" + + "github.com/go-faster/errors" + "go.opentelemetry.io/otel/metric" +) + +var ( + int64CounterType = reflect.TypeOf(new(metric.Int64Counter)).Elem() + int64UpDownCounterType = reflect.TypeOf(new(metric.Int64UpDownCounter)).Elem() + int64HistogramType = reflect.TypeOf(new(metric.Int64Histogram)).Elem() + int64GaugeType = reflect.TypeOf(new(metric.Int64Gauge)).Elem() + int64ObservableCounterType = reflect.TypeOf(new(metric.Int64ObservableCounter)).Elem() + int64ObservableUpDownCounterType = reflect.TypeOf(new(metric.Int64ObservableUpDownCounter)).Elem() + int64ObservableGaugeType = reflect.TypeOf(new(metric.Int64ObservableGauge)).Elem() +) + +var ( + float64CounterType = reflect.TypeOf(new(metric.Float64Counter)).Elem() + float64UpDownCounterType = reflect.TypeOf(new(metric.Float64UpDownCounter)).Elem() + float64HistogramType = reflect.TypeOf(new(metric.Float64Histogram)).Elem() + float64GaugeType = reflect.TypeOf(new(metric.Float64Gauge)).Elem() + float64ObservableCounterType = reflect.TypeOf(new(metric.Float64ObservableCounter)).Elem() + float64ObservableUpDownCounterType = reflect.TypeOf(new(metric.Float64ObservableUpDownCounter)).Elem() + float64ObservableGaugeType = reflect.TypeOf(new(metric.Float64ObservableGauge)).Elem() +) + +// InitOptions defines options for [Init]. +type InitOptions struct { + // Prefix defines common prefix for all metrics. + Prefix string + // FieldName returns name for given field. + FieldName func(prefix string, sf reflect.StructField) string +} + +func (opts *InitOptions) setDefaults() { + if opts.FieldName == nil { + opts.FieldName = fieldName + } +} + +func fieldName(prefix string, sf reflect.StructField) string { + name := snakeCase(sf.Name) + if tag, ok := sf.Tag.Lookup("name"); ok { + name = tag + } + return prefix + name +} + +// Init initialize metrics in given struct s using given meter. +func Init(m metric.Meter, s any, opts InitOptions) error { + opts.setDefaults() + + ptr := reflect.ValueOf(s) + if !isValidPtrStruct(ptr) { + return errors.Errorf("a pointer-to-struct expected, got %T", s) + } + + var ( + struct_ = ptr.Elem() + structType = struct_.Type() + ) + for i := 0; i < struct_.NumField(); i++ { + fieldType := structType.Field(i) + if fieldType.Anonymous || !fieldType.IsExported() { + continue + } + if n, ok := fieldType.Tag.Lookup("autometric"); ok && n == "-" { + continue + } + + field := struct_.Field(i) + if !field.CanSet() { + continue + } + + mt, err := makeField(m, fieldType, opts) + if err != nil { + return errors.Wrapf(err, "field (%s).%s", structType, fieldType.Name) + } + field.Set(reflect.ValueOf(mt)) + } + + return nil +} + +func makeField(m metric.Meter, sf reflect.StructField, opts InitOptions) (any, error) { + var ( + name = opts.FieldName(opts.Prefix, sf) + unit = sf.Tag.Get("unit") + desc = sf.Tag.Get("description") + boundaries []float64 + ) + if b, ok := sf.Tag.Lookup("boundaries"); ok { + switch ftyp := sf.Type; ftyp { + case int64HistogramType, float64HistogramType: + default: + return nil, errors.Errorf("boundaries tag should be used only on histogram metrics: got %v", ftyp) + } + for _, val := range strings.Split(b, ",") { + f, err := strconv.ParseFloat(val, 64) + if err != nil { + return nil, errors.Wrap(err, "parse boundaries") + } + boundaries = append(boundaries, f) + } + } + + switch ftyp := sf.Type; ftyp { + case int64CounterType: + return m.Int64Counter(name, + metric.WithUnit(unit), + metric.WithDescription(desc), + ) + case int64UpDownCounterType: + return m.Int64UpDownCounter(name, + metric.WithUnit(unit), + metric.WithDescription(desc), + ) + case int64HistogramType: + return m.Int64Histogram(name, + metric.WithUnit(unit), + metric.WithDescription(desc), + metric.WithExplicitBucketBoundaries(boundaries...), + ) + case int64GaugeType: + return m.Int64Gauge(name, + metric.WithUnit(unit), + metric.WithDescription(desc), + ) + case int64ObservableCounterType, + int64ObservableUpDownCounterType, + int64ObservableGaugeType: + return nil, errors.New("observables are not supported") + + case float64CounterType: + return m.Float64Counter(name, + metric.WithUnit(unit), + metric.WithDescription(desc), + ) + case float64UpDownCounterType: + return m.Float64UpDownCounter(name, + metric.WithUnit(unit), + metric.WithDescription(desc), + ) + case float64HistogramType: + return m.Float64Histogram(name, + metric.WithUnit(unit), + metric.WithDescription(desc), + metric.WithExplicitBucketBoundaries(boundaries...), + ) + case float64GaugeType: + return m.Float64Gauge(name, + metric.WithUnit(unit), + metric.WithDescription(desc), + ) + case float64ObservableCounterType, + float64ObservableUpDownCounterType, + float64ObservableGaugeType: + return nil, errors.New("observables are not supported") + default: + return nil, errors.Errorf("unexpected type %v", ftyp) + } +} + +func isValidPtrStruct(ptr reflect.Value) bool { + return ptr.Kind() == reflect.Pointer && + ptr.Elem().Kind() == reflect.Struct +} diff --git a/autometric/autometric_test.go b/autometric/autometric_test.go new file mode 100644 index 0000000..b0dd15b --- /dev/null +++ b/autometric/autometric_test.go @@ -0,0 +1,168 @@ +// Package autometric contains a simple reflect-based OpenTelemetry metric initializer. +package autometric + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/metric" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" +) + +func TestInit(t *testing.T) { + ctx := context.Background() + + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + meter := mp.Meter("test-meter") + + var test struct { + // Ignored fields. + _ int + _ metric.Int64Counter + // Embedded fields. + fmt.Stringer + // Private fields. + private int + privateCounter metric.Int64Counter + // Skip. + SkipMe metric.Int64Counter `autometric:"-"` + SkipMe2 metric.Int64ObservableCounter `autometric:"-"` + + Int64Counter metric.Int64Counter + Int64UpDownCounter metric.Int64UpDownCounter + Int64Histogram metric.Int64Histogram + Int64Gauge metric.Int64Gauge + Float64Counter metric.Float64Counter + Float64UpDownCounter metric.Float64UpDownCounter + Float64Histogram metric.Float64Histogram + Float64Gauge metric.Float64Gauge + + Renamed metric.Int64Counter `name:"mega_counter"` + WithDesc metric.Int64Counter `name:"with_desc" description:"foo"` + WithUnit metric.Int64Counter `name:"with_unit" unit:"By"` + WithBounds metric.Float64Histogram `name:"with_bounds" boundaries:"1,2,5"` + } + const prefix = "testmetrics.points." + require.NoError(t, Init(meter, &test, InitOptions{ + Prefix: prefix, + })) + + require.Nil(t, test.privateCounter) + + require.NotNil(t, test.Int64Counter) + test.Int64Counter.Add(ctx, 1) + require.NotNil(t, test.Int64UpDownCounter) + test.Int64UpDownCounter.Add(ctx, 1) + require.NotNil(t, test.Int64Histogram) + test.Int64Histogram.Record(ctx, 1) + require.NotNil(t, test.Int64Gauge) + test.Int64Gauge.Record(ctx, 1) + require.NotNil(t, test.Float64Counter) + test.Float64Counter.Add(ctx, 1) + require.NotNil(t, test.Float64UpDownCounter) + test.Float64UpDownCounter.Add(ctx, 1) + require.NotNil(t, test.Float64Histogram) + test.Float64Histogram.Record(ctx, 1) + require.NotNil(t, test.Float64Gauge) + test.Float64Gauge.Record(ctx, 1) + + require.NotNil(t, test.Renamed) + test.Renamed.Add(ctx, 1) + require.NotNil(t, test.WithDesc) + test.WithDesc.Add(ctx, 1) + require.NotNil(t, test.WithUnit) + test.WithUnit.Add(ctx, 1) + require.NotNil(t, test.WithBounds) + test.WithBounds.Record(ctx, 1) + + require.NoError(t, mp.ForceFlush(ctx)) + var data metricdata.ResourceMetrics + require.NoError(t, reader.Collect(ctx, &data)) + + type MetricInfo struct { + Name string + Description string + Unit string + } + var infos []MetricInfo + for _, scope := range data.ScopeMetrics { + for _, metric := range scope.Metrics { + infos = append(infos, MetricInfo{ + Name: metric.Name, + Description: metric.Description, + Unit: metric.Unit, + }) + } + } + require.Equal(t, + []MetricInfo{ + {Name: prefix + "int64_counter"}, + {Name: prefix + "int64_up_down_counter"}, + {Name: prefix + "int64_histogram"}, + {Name: prefix + "int64_gauge"}, + {Name: prefix + "float64_counter"}, + {Name: prefix + "float64_up_down_counter"}, + {Name: prefix + "float64_histogram"}, + {Name: prefix + "float64_gauge"}, + + {Name: prefix + "mega_counter"}, + {Name: prefix + "with_desc", Description: "foo"}, + {Name: prefix + "with_unit", Unit: "By"}, + {Name: prefix + "with_bounds"}, + }, + infos, + ) +} + +func TestInitErrors(t *testing.T) { + type ( + JustStruct struct{} + + UnexpectedType struct { + Foo metric.Observable + } + UnsupportedInt64Observable struct { + Observable metric.Int64ObservableCounter + } + UnsupportedFloat64Observable struct { + Observable metric.Float64ObservableCounter + } + + BoundariesOnNonHistogram struct { + C metric.Int64Counter `boundaries:"foo"` + } + + BadBoundaries struct { + H metric.Float64Histogram `boundaries:"foo"` + } + BadBoundaries2 struct { + H metric.Float64Histogram `boundaries:"foo,"` + } + ) + + for i, tt := range []struct { + s any + err string + }{ + {0, "a pointer-to-struct expected, got int"}, + {JustStruct{}, "a pointer-to-struct expected, got autometric.JustStruct"}, + + {&UnexpectedType{}, "field (autometric.UnexpectedType).Foo: unexpected type metric.Observable"}, + {&UnsupportedInt64Observable{}, "field (autometric.UnsupportedInt64Observable).Observable: observables are not supported"}, + {&UnsupportedFloat64Observable{}, "field (autometric.UnsupportedFloat64Observable).Observable: observables are not supported"}, + + {&BoundariesOnNonHistogram{}, `field (autometric.BoundariesOnNonHistogram).C: boundaries tag should be used only on histogram metrics: got metric.Int64Counter`}, + {&BadBoundaries{}, `field (autometric.BadBoundaries).H: parse boundaries: strconv.ParseFloat: parsing "foo": invalid syntax`}, + {&BadBoundaries2{}, `field (autometric.BadBoundaries2).H: parse boundaries: strconv.ParseFloat: parsing "foo": invalid syntax`}, + } { + t.Run(fmt.Sprintf("Test%d", i+1), func(t *testing.T) { + mp := sdkmetric.NewMeterProvider() + meter := mp.Meter("test-meter") + require.EqualError(t, Init(meter, tt.s, InitOptions{}), tt.err) + }) + } +} diff --git a/autometric/strcase.go b/autometric/strcase.go new file mode 100644 index 0000000..42bd294 --- /dev/null +++ b/autometric/strcase.go @@ -0,0 +1,71 @@ +package autometric + +import ( + "strings" + "unicode" +) + +func snakeCase(s string) string { + const delim = '_' + s = strings.TrimSpace(s) + for _, c := range s { + if isUpper(c) { + goto slow + } + } + return s + +slow: + var sb strings.Builder + sb.Grow(len(s) + 8) + + var prev, curr rune + for i, next := range s { + switch { + case isDelim(curr): + if !isDelim(prev) { + sb.WriteByte(delim) + } + case isUpper(curr): + if isLower(prev) || + (isUpper(prev) && isLower(next)) || + (isDigit(prev) && isAlpha(next)) { + sb.WriteByte(delim) + } + sb.WriteRune(unicode.ToLower(curr)) + case i != 0: + sb.WriteRune(unicode.ToLower(curr)) + } + prev = curr + curr = next + } + + if s != "" { + if isUpper(curr) && isLower(prev) { + sb.WriteByte(delim) + } + sb.WriteRune(unicode.ToLower(curr)) + } + + return sb.String() +} + +func isDelim(ch rune) bool { + return unicode.IsSpace(ch) || ch == '_' || ch == '-' +} + +func isAlpha(ch rune) bool { + return isUpper(ch) || isLower(ch) +} + +func isDigit(ch rune) bool { + return ch >= '0' && ch <= '9' +} + +func isUpper(ch rune) bool { + return ch >= 'A' && ch <= 'Z' +} + +func isLower(ch rune) bool { + return ch >= 'a' && ch <= 'z' +} diff --git a/autometric/strcase_test.go b/autometric/strcase_test.go new file mode 100644 index 0000000..95700c7 --- /dev/null +++ b/autometric/strcase_test.go @@ -0,0 +1,33 @@ +package autometric + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_snakeCase(t *testing.T) { + tests := []struct { + s string + want string + }{ + {"", ""}, + {"f", "f"}, + {"F", "f"}, + {"Foo", "foo"}, + {"FooB", "foo_b"}, + {" FooBar\t", "foo_bar"}, + {"foo__Bar", "foo_bar"}, + {"foo--Bar", "foo_bar"}, + {"foo Bar", "foo_bar"}, + {"foo\tBar", "foo_bar"}, + {"Int64UpDownCounter", "int64_up_down_counter"}, + } + for i, tt := range tests { + tt := tt + t.Run(fmt.Sprintf("Test%d", i+1), func(t *testing.T) { + require.Equal(t, tt.want, snakeCase(tt.s)) + }) + } +}