-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
introduce ctats subpackage for metrics
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
1 parent
078ed21
commit 72b7e4c
Showing
11 changed files
with
583 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
package ctats | ||
|
||
// coming... |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) | ||
} | ||
} |
Oops, something went wrong.