Skip to content

Commit

Permalink
introduce ctats subpackage for metrics (#80)
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)
- View configuration as part of otel meter initialization
  • Loading branch information
ryanfkeepers authored Dec 4, 2024
1 parent 078ed21 commit c756e34
Show file tree
Hide file tree
Showing 11 changed files with 770 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
152 changes: 152 additions & 0 deletions ctats/counter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
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")
}

ctr, err := nc.OTELMeter().Float64UpDownCounter(id)
if err != nil {
return nil, errors.Wrap(err, "making new counter")
}

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

return ctr, nil
}

// 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)
}
61 changes: 61 additions & 0 deletions ctats/counter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package ctats

import (
"context"
"testing"

"github.com/alcionai/clues/internal/node"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCounter(t *testing.T) {
noc, err := node.NewOTELClient(
context.Background(),
t.Name(),
node.OTELConfig{})
require.NoError(t, err)

ctx := node.EmbedInCtx(context.Background(), &node.Node{OTEL: noc})

ctx, err = Initialize(ctx)
require.NoError(t, err)

ctx, err = RegisterCounter(ctx, "reg.c", "test", "testing counter")
require.NoError(t, err)

metricBus := fromCtx(ctx)

assert.Contains(t, metricBus.counters, "reg.c")
assert.Len(t, metricBus.counters, 1)
assert.NotContains(t, metricBus.gauges, "reg.c")
assert.Len(t, metricBus.gauges, 0)
assert.NotContains(t, metricBus.histograms, "reg.c")
assert.Len(t, metricBus.histograms, 0)

Counter[int64]("reg.c").Add(ctx, 1)
Counter[float64]("reg.c").Add(ctx, 1)
Counter[int64]("reg.c").Inc(ctx)
Counter[float64]("reg.c").Inc(ctx)
Counter[int64]("reg.c").Dec(ctx)
Counter[float64]("reg.c").Dec(ctx)

assert.Contains(t, metricBus.counters, "reg.c")
assert.Len(t, metricBus.counters, 1)
assert.NotContains(t, metricBus.gauges, "reg.c")
assert.Len(t, metricBus.gauges, 0)
assert.NotContains(t, metricBus.histograms, "reg.c")
assert.Len(t, metricBus.histograms, 0)

Counter[int8]("c").Add(ctx, 1)
Counter[float32]("c").Inc(ctx)
Counter[uint16]("c").Dec(ctx)
Counter[int]("c").Dec(ctx)

assert.Contains(t, metricBus.counters, "c")
assert.Len(t, metricBus.counters, 2)
assert.NotContains(t, metricBus.gauges, "c")
assert.Len(t, metricBus.gauges, 0)
assert.NotContains(t, metricBus.histograms, "c")
assert.Len(t, metricBus.histograms, 0)
}
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)
}
Loading

0 comments on commit c756e34

Please sign in to comment.