Skip to content

Commit

Permalink
feat(autometric): init
Browse files Browse the repository at this point in the history
Copy implementation from oteldb by tdakkota
  • Loading branch information
ernado committed Jan 26, 2025
1 parent 8be7667 commit 273e481
Show file tree
Hide file tree
Showing 5 changed files with 447 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
174 changes: 174 additions & 0 deletions autometric/autometric.go
Original file line number Diff line number Diff line change
@@ -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
}
168 changes: 168 additions & 0 deletions autometric/autometric_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
Loading

0 comments on commit 273e481

Please sign in to comment.