Skip to content

Commit

Permalink
introduce ctats subpackage for metrics
Browse files Browse the repository at this point in the history
ctats (yes, pronounced "stats"; what, do you have a better name?
Please, please tell me if you do...) is the OTEL metrics wrapper for
clues.  It seeks to reduce the current metrics interface into two basic
steps: 1/ recording metrics.  2/ optional pre-registration of
data-points to record.

Most of the API is designed towards simplification of the OTEL interface
into something that's approachable for generic development.  This
introduction leaves a few things to the side for later development:

- unit testing (I should have this in place before the PR is complete)
- multi-thread environment safety
- maybe auto-initialization of system runtime metrics (cpu, memory, gc,
  etc)
  • Loading branch information
ryanfkeepers committed Nov 27, 2024
1 parent 59102b6 commit 1515bdb
Show file tree
Hide file tree
Showing 11 changed files with 583 additions and 6 deletions.
6 changes: 2 additions & 4 deletions clog/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,8 @@ func (b builder) log(l logLevel, msg string) {
// add otel logging if provided
otelLogger := b.otel

if otelLogger == nil &&
cluesNode.OTEL != nil &&
cluesNode.OTEL.Logger != nil {
otelLogger = cluesNode.OTEL.Logger
if otelLogger == nil && cluesNode.OTELLogger() != nil {
otelLogger = cluesNode.OTELLogger()
}

if otelLogger != nil {
Expand Down
4 changes: 2 additions & 2 deletions clog/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,8 @@ func singleton(ctx context.Context, set Settings) *clogger {

node := clues.In(ctx)

if node.OTEL != nil && node.OTEL.Logger != nil {
cloggerton.otel = node.OTEL.Logger
if node.OTELLogger() != nil {
cloggerton.otel = node.OTELLogger()
}

return cloggerton
Expand Down
144 changes: 144 additions & 0 deletions ctats/counter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package ctats

import (
"context"
"fmt"

"github.com/pkg/errors"
"go.opentelemetry.io/otel/metric"

"github.com/alcionai/clues/internal/node"
)

// counterFromCtx retrieves the counter instance from the metrics bus
// in the context. If the ctx has no metrics bus, or if the bus does
// not have a counter for the provided ID, returns nil.
func counterFromCtx(
ctx context.Context,
id string,
) metric.Float64UpDownCounter {
b := fromCtx(ctx)
if b == nil {
return nil
}

return b.counters[formatID(id)]
}

// getOrCreateCounter attempts to retrieve a counter from the
// context with the given ID. If it is unable to find a counter
// with that ID, a new counter is generated.
func getOrCreateCounter(
ctx context.Context,
id string,
) (metric.Float64UpDownCounter, error) {
id = formatID(id)

ctr := counterFromCtx(ctx, id)
if ctr != nil {
return ctr, nil
}

// make a new one
nc := node.FromCtx(ctx)
if nc.OTEL == nil {
return nil, errors.New("no node in ctx")
}

return nc.OTELMeter().Float64UpDownCounter(id)
}

// RegisterCounter introduces a new counter with the given unit and description.
// If RegisterCounter is not called before updating a metric value, a counter with
// no unit or description is created. If RegisterCounter is called for an ID that
// has already been registered, it no-ops.
func RegisterCounter(
ctx context.Context,
// all lowercase, period delimited id of the counter. Ex: "http.response.status_code"
id string,
// (optional) the unit of measurement. Ex: "byte", "kB", "fnords"
unit string,
// (optional) a short description about the metric. Ex: "number of times we saw the fnords".
description string,
) (context.Context, error) {
id = formatID(id)

// if we already have a counter registered to that ID, do nothing.
ctr := counterFromCtx(ctx, id)
if ctr != nil {
return ctx, nil
}

// can't do anything if otel hasn't been initialized.
nc := node.FromCtx(ctx)
if nc.OTEL == nil {
return ctx, errors.New("no clues in ctx")
}

opts := []metric.Float64UpDownCounterOption{}

if len(description) > 0 {
opts = append(opts, metric.WithDescription(description))
}

if len(unit) > 0 {
opts = append(opts, metric.WithUnit(unit))
}

// register the counter
ctr, err := nc.OTELMeter().Float64UpDownCounter(id, opts...)
if err != nil {
return ctx, errors.Wrap(err, "creating counter")
}

cb := fromCtx(ctx)
cb.counters[id] = ctr

return embedInCtx(ctx, cb), nil
}

// Counter returns a counter factory for the provided id.
// If a Counter instance has been registered for that ID, the
// registered instance will be used. If not, a new instance
// will get generated.
func Counter[N number](id string) counter[N] {
return counter[N]{base{formatID(id)}}
}

// counter provides access to the factory functions.
type counter[N number] struct {
base
}

// Add increments the counter by n. n can be negative.
func (c counter[number]) Add(ctx context.Context, n number) {
ctr, err := getOrCreateCounter(ctx, c.getID())
if err != nil {
fmt.Printf("err getting counter: %+v\n", err)
return
}

ctr.Add(ctx, float64(n))
}

// Inc is shorthand for Add(ctx, 1).
func (c counter[number]) Inc(ctx context.Context) {
ctr, err := getOrCreateCounter(ctx, c.getID())
if err != nil {
fmt.Printf("err getting counter: %+v\n", err)
return
}

ctr.Add(ctx, 1.0)
}

// Dec is shorthand for Add(ctx, -1).
func (c counter[number]) Dec(ctx context.Context) {
ctr, err := getOrCreateCounter(ctx, c.getID())
if err != nil {
fmt.Printf("err getting counter: %+v\n", err)
return
}

ctr.Add(ctx, -1.0)
}
3 changes: 3 additions & 0 deletions ctats/counter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package ctats

// coming...
95 changes: 95 additions & 0 deletions ctats/ctats.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package ctats

import (
"context"
"regexp"
"strings"

"go.opentelemetry.io/otel/metric"

"github.com/alcionai/clues/internal/node"
"github.com/pkg/errors"
)

// ---------------------------------------------------------------------------
// ctx handling
// ---------------------------------------------------------------------------

type metricsBusKey string

const defaultCtxKey metricsBusKey = "default_metrics_bus_key"

func fromCtx(ctx context.Context) *bus {
dn := ctx.Value(defaultCtxKey)

if dn == nil {
return nil
}

return dn.(*bus)
}

func embedInCtx(ctx context.Context, b *bus) context.Context {
return context.WithValue(ctx, defaultCtxKey, b)
}

type bus struct {
counters map[string]metric.Float64UpDownCounter
gauges map[string]metric.Float64Gauge
histograms map[string]metric.Float64Histogram
}

// Initialize ensures that a metrics collector exists in the ctx.
// If the ctx has not already run clues.Initialize() and generated
// OTEL connection details, an error is returned.
//
// Multiple calls to Initialize will no-op all after the first.
func Initialize(ctx context.Context) (context.Context, error) {
nc := node.FromCtx(ctx)
if nc == nil || nc.OTEL == nil {
return ctx, errors.New("clues.Initialize has not been run on this context")
}

if fromCtx(ctx) != nil {
return ctx, nil
}

b := &bus{
counters: map[string]metric.Float64UpDownCounter{},
gauges: map[string]metric.Float64Gauge{},
histograms: map[string]metric.Float64Histogram{},
}

return embedInCtx(ctx, b), nil
}

// number covers the values that callers are allowed to provide
// to the metrics factories. No matter the provided value, a
// float64 will be recorded to the metrics collector.
type number interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64
}

// base contains the properties common to all metrics factories.
type base struct {
id string
}

func (b base) getID() string {
return formatID(b.id)
}

var (
camel = regexp.MustCompile("([a-z0-9])([A-Z])")
)

// formatID transforms kebab-case and camelCase to dot.delimited case,
// replaces all spaces with underscores, and lowers the string.
func formatID(id string) string {
id = strings.ReplaceAll(id, " ", "_")
id = camel.ReplaceAllString(id, "$1.$2")
id = strings.ReplaceAll(id, "-", ".")
return strings.ToLower(id)
}
67 changes: 67 additions & 0 deletions ctats/ctats_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package ctats

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestFormatID(t *testing.T) {
table := []struct {
name string
in string
expect string
}{
{
name: "empty",
in: "",
expect: "",
},
{
name: "simple",
in: "foobarbaz",
expect: "foobarbaz",
},
{
name: "already correct",
in: "foo.bar.baz",
expect: "foo.bar.baz",
},
{
name: "only underscore delimited",
in: "foo_bar_baz",
expect: "foo_bar_baz",
},
{
name: "spaces to underscores",
in: "foo bar baz",
expect: "foo_bar_baz",
},
{
name: "camel case",
in: "FooBarBaz",
expect: "foo.bar.baz",
},
{
name: "all caps",
in: "FOOBARBAZ",
expect: "foobarbaz",
},
{
name: "kebab case",
in: "foo-bar-baz",
expect: "foo.bar.baz",
},
{
name: "mixed",
in: "fooBar baz-fnords",
expect: "foo.bar_baz.fnords",
},
}
for _, test := range table {
t.Run(test.name, func(t *testing.T) {
result := formatID(test.in)
assert.Equal(t, test.expect, result, "input: %s", test.in)
})
}
}
Loading

0 comments on commit 1515bdb

Please sign in to comment.