diff --git a/README.md b/README.md index e672bc5..a1f2545 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,24 @@ make run-example group=eviction # or any other example For a complete list of examples, refer to the [examples](./__examples/README.md) directory. +### Observability (OpenTelemetry) + +HyperCache provides optional OpenTelemetry middleware for tracing and metrics. + +- Tracing: wrap the service with `middleware.NewOTelTracingMiddleware` using a `trace.Tracer`. +- Metrics: wrap with `middleware.NewOTelMetricsMiddleware` using a `metric.Meter`. + +Example wiring (see `__examples/observability/otel.go`): + +```go +svc := hypercache.ApplyMiddleware(svc, + func(next hypercache.Service) hypercache.Service { return middleware.NewOTelTracingMiddleware(next, tracer) }, + func(next hypercache.Service) hypercache.Service { mw, _ := middleware.NewOTelMetricsMiddleware(next, meter); return mw }, +) +``` + +Use your preferred OpenTelemetry SDK setup for exporters and processors in production; the example uses no-op providers for simplicity. + ## API The `NewInMemoryWithDefaults` function creates a new `HyperCache` instance with the defaults: diff --git a/__examples/README.md b/__examples/README.md index 335a605..6e66c39 100644 --- a/__examples/README.md +++ b/__examples/README.md @@ -21,3 +21,5 @@ All the code in this directory is for demonstration purposes only. 8. [`Middleware`](./middleware/middleware.go) - An example of implementing a custom middleware and register it with the `HyperCacheService`. 9. [`Size`](./size/size.go) - An example of using the HyperCache package to store a list of items and limit the cache based on size. + +10. [`Observability (OpenTelemetry)`](./observability/otel.go) - Demonstrates wrapping the service with tracing and metrics middleware using OpenTelemetry. diff --git a/__examples/observability/otel.go b/__examples/observability/otel.go new file mode 100644 index 0000000..31bae8f --- /dev/null +++ b/__examples/observability/otel.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric/noop" + "go.opentelemetry.io/otel/trace" + + "github.com/hyp3rd/hypercache" + "github.com/hyp3rd/hypercache/pkg/middleware" +) + +// This example shows how to wrap HyperCache with OpenTelemetry middleware. +func main() { + cache, err := hypercache.NewInMemoryWithDefaults(16) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + + // Build a service from the cache to apply middleware. + svc := hypercache.Service(cache) + + // Use noop providers for a minimal example. Replace with real SDK providers in production. + meter := noop.NewMeterProvider().Meter("hypercache/examples") + tracer := trace.NewNoopTracerProvider().Tracer("hypercache/examples") + + // Apply OTel tracing and metrics middleware. + svc = hypercache.ApplyMiddleware(svc, + func(next hypercache.Service) hypercache.Service { + return middleware.NewOTelTracingMiddleware(next, tracer, middleware.WithCommonAttributes( + attribute.String("component", "hypercache"), + )) + }, + func(next hypercache.Service) hypercache.Service { + mw, _ := middleware.NewOTelMetricsMiddleware(next, meter) + return mw + }, + ) + defer svc.Stop() + + _ = svc.Set(context.Background(), "key", "value", time.Minute) + if v, ok := svc.Get(context.Background(), "key"); ok { + fmt.Println("got:", v) + } +} diff --git a/go.mod b/go.mod index 0efd5a6..837c2c6 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,9 @@ require ( github.com/redis/go-redis/v9 v9.12.1 github.com/shamaton/msgpack/v2 v2.3.0 github.com/ugorji/go/codec v1.3.0 + go.opentelemetry.io/otel v1.37.0 + go.opentelemetry.io/otel/metric v1.37.0 + go.opentelemetry.io/otel/trace v1.37.0 ) require ( diff --git a/go.sum b/go.sum index 0368a20..ebda5c7 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -28,6 +32,14 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/pkg/middleware/otel_metrics.go b/pkg/middleware/otel_metrics.go new file mode 100644 index 0000000..8469fcc --- /dev/null +++ b/pkg/middleware/otel_metrics.go @@ -0,0 +1,150 @@ +package middleware + +import ( + "context" + "fmt" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/hyp3rd/hypercache" + "github.com/hyp3rd/hypercache/pkg/backend" + "github.com/hyp3rd/hypercache/pkg/cache" + "github.com/hyp3rd/hypercache/pkg/stats" +) + +// OTelMetricsMiddleware emits OpenTelemetry metrics for service methods. +type OTelMetricsMiddleware struct { + next hypercache.Service + meter metric.Meter + + // instruments + calls metric.Int64Counter + durations metric.Float64Histogram +} + +// NewOTelMetricsMiddleware constructs a metrics middleware using the provided meter. +func NewOTelMetricsMiddleware(next hypercache.Service, meter metric.Meter) (hypercache.Service, error) { + calls, err := meter.Int64Counter("hypercache.calls") + if err != nil { + return nil, fmt.Errorf("create counter: %w", err) + } + + durations, err := meter.Float64Histogram("hypercache.duration.ms") + if err != nil { + return nil, fmt.Errorf("create histogram: %w", err) + } + + return &OTelMetricsMiddleware{next: next, meter: meter, calls: calls, durations: durations}, nil +} + +// Get implements Service.Get with metrics. +func (mw *OTelMetricsMiddleware) Get(ctx context.Context, key string) (any, bool) { + start := time.Now() + v, ok := mw.next.Get(ctx, key) + mw.rec(ctx, "Get", start, attribute.Int("key.len", len(key)), attribute.Bool("hit", ok)) + + return v, ok +} + +// Set implements Service.Set with metrics. +func (mw *OTelMetricsMiddleware) Set(ctx context.Context, key string, value any, expiration time.Duration) error { + start := time.Now() + err := mw.next.Set(ctx, key, value, expiration) + mw.rec(ctx, "Set", start, attribute.Int("key.len", len(key))) + + return err +} + +// GetOrSet implements Service.GetOrSet with metrics. +func (mw *OTelMetricsMiddleware) GetOrSet(ctx context.Context, key string, value any, expiration time.Duration) (any, error) { + start := time.Now() + v, err := mw.next.GetOrSet(ctx, key, value, expiration) + mw.rec(ctx, "GetOrSet", start, attribute.Int("key.len", len(key))) + + return v, err +} + +// GetWithInfo implements Service.GetWithInfo with metrics. +func (mw *OTelMetricsMiddleware) GetWithInfo(ctx context.Context, key string) (*cache.Item, bool) { + start := time.Now() + it, ok := mw.next.GetWithInfo(ctx, key) + mw.rec(ctx, "GetWithInfo", start, attribute.Int("key.len", len(key)), attribute.Bool("hit", ok)) + + return it, ok +} + +// GetMultiple implements Service.GetMultiple with metrics. +func (mw *OTelMetricsMiddleware) GetMultiple(ctx context.Context, keys ...string) (map[string]any, map[string]error) { + start := time.Now() + res, failed := mw.next.GetMultiple(ctx, keys...) + mw.rec(ctx, "GetMultiple", start, attribute.Int("keys.count", len(keys)), attribute.Int("result.count", len(res)), attribute.Int("failed.count", len(failed))) + + return res, failed +} + +// List implements Service.List with metrics. +func (mw *OTelMetricsMiddleware) List(ctx context.Context, filters ...backend.IFilter) ([]*cache.Item, error) { + start := time.Now() + items, err := mw.next.List(ctx, filters...) + + n := 0 + if items != nil { + n = len(items) + } + + mw.rec(ctx, "List", start, attribute.Int("items.count", n)) + + return items, err +} + +// Remove implements Service.Remove with metrics. +func (mw *OTelMetricsMiddleware) Remove(ctx context.Context, keys ...string) error { + start := time.Now() + err := mw.next.Remove(ctx, keys...) + mw.rec(ctx, "Remove", start, attribute.Int("keys.count", len(keys))) + + return err +} + +// Clear implements Service.Clear with metrics. +func (mw *OTelMetricsMiddleware) Clear(ctx context.Context) error { + start := time.Now() + err := mw.next.Clear(ctx) + mw.rec(ctx, "Clear", start) + + return err +} + +// Capacity returns cache capacity. +func (mw *OTelMetricsMiddleware) Capacity() int { return mw.next.Capacity() } + +// Allocation returns allocated size. +func (mw *OTelMetricsMiddleware) Allocation() int64 { return mw.next.Allocation() } + +// Count returns items count. +func (mw *OTelMetricsMiddleware) Count(ctx context.Context) int { return mw.next.Count(ctx) } + +// TriggerEviction triggers eviction. +func (mw *OTelMetricsMiddleware) TriggerEviction() { mw.next.TriggerEviction() } + +// Stop stops the underlying service. +func (mw *OTelMetricsMiddleware) Stop() { mw.next.Stop() } + +// GetStats returns stats. +func (mw *OTelMetricsMiddleware) GetStats() stats.Stats { return mw.next.GetStats() } + +// rec records call count and duration with attributes. +// Moved to the end to satisfy funcorder linters. +func (mw *OTelMetricsMiddleware) rec(ctx context.Context, method string, start time.Time, attrs ...attribute.KeyValue) { + base := []attribute.KeyValue{attribute.String("method", method)} + if len(attrs) > 0 { + base = append(base, attrs...) + } + + mw.calls.Add(ctx, 1, metric.WithAttributes(base...)) + mw.durations.Record(ctx, float64(time.Since(start).Milliseconds()), metric.WithAttributes(base...)) +} + +// keep helpers at end of file diff --git a/pkg/middleware/otel_tracing.go b/pkg/middleware/otel_tracing.go new file mode 100644 index 0000000..0ce0963 --- /dev/null +++ b/pkg/middleware/otel_tracing.go @@ -0,0 +1,185 @@ +// Package middleware contains service middlewares for hypercache. +package middleware + +import ( + "context" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + + "github.com/hyp3rd/hypercache" + "github.com/hyp3rd/hypercache/pkg/backend" + "github.com/hyp3rd/hypercache/pkg/cache" + "github.com/hyp3rd/hypercache/pkg/stats" +) + +// OTelTracingMiddleware wraps hypercache.Service methods with OpenTelemetry spans. +type OTelTracingMiddleware struct { + next hypercache.Service + tracer trace.Tracer + // static attributes applied to all spans + commonAttrs []attribute.KeyValue +} + +// OTelTracingOption allows configuring the tracing middleware. +type OTelTracingOption func(*OTelTracingMiddleware) + +// WithCommonAttributes sets attributes applied to all spans. +func WithCommonAttributes(attrs ...attribute.KeyValue) OTelTracingOption { + return func(m *OTelTracingMiddleware) { m.commonAttrs = append(m.commonAttrs, attrs...) } +} + +// NewOTelTracingMiddleware creates a tracing middleware. +func NewOTelTracingMiddleware(next hypercache.Service, tracer trace.Tracer, opts ...OTelTracingOption) hypercache.Service { + mw := &OTelTracingMiddleware{next: next, tracer: tracer} + for _, o := range opts { + o(mw) + } + + return mw +} + +// Get implements Service.Get with tracing. +func (mw OTelTracingMiddleware) Get(ctx context.Context, key string) (any, bool) { + ctx, span := mw.startSpan(ctx, "hypercache.Get", attribute.Int("key.len", len(key))) + defer span.End() + + v, ok := mw.next.Get(ctx, key) + span.SetAttributes(attribute.Bool("hit", ok)) + + return v, ok +} + +// Set implements Service.Set with tracing. +func (mw OTelTracingMiddleware) Set(ctx context.Context, key string, value any, expiration time.Duration) error { + ctx, span := mw.startSpan(ctx, "hypercache.Set", attribute.Int("key.len", len(key)), attribute.Int64("expiration.ms", expiration.Milliseconds())) + defer span.End() + + err := mw.next.Set(ctx, key, value, expiration) + if err != nil { + span.RecordError(err) + } + + return err +} + +// GetOrSet implements Service.GetOrSet with tracing. +func (mw OTelTracingMiddleware) GetOrSet(ctx context.Context, key string, value any, expiration time.Duration) (any, error) { + ctx, span := mw.startSpan(ctx, "hypercache.GetOrSet", attribute.Int("key.len", len(key)), attribute.Int64("expiration.ms", expiration.Milliseconds())) + defer span.End() + + v, err := mw.next.GetOrSet(ctx, key, value, expiration) + if err != nil { + span.RecordError(err) + } + + return v, err +} + +// GetWithInfo implements Service.GetWithInfo with tracing. +func (mw OTelTracingMiddleware) GetWithInfo(ctx context.Context, key string) (*cache.Item, bool) { + ctx, span := mw.startSpan(ctx, "hypercache.GetWithInfo", attribute.Int("key.len", len(key))) + defer span.End() + + it, ok := mw.next.GetWithInfo(ctx, key) + span.SetAttributes(attribute.Bool("hit", ok)) + + return it, ok +} + +// GetMultiple implements Service.GetMultiple with tracing. +func (mw OTelTracingMiddleware) GetMultiple(ctx context.Context, keys ...string) (map[string]any, map[string]error) { + ctx, span := mw.startSpan(ctx, "hypercache.GetMultiple", attribute.Int("keys.count", len(keys))) + defer span.End() + + res, failed := mw.next.GetMultiple(ctx, keys...) + span.SetAttributes(attribute.Int("result.count", len(res)), attribute.Int("failed.count", len(failed))) + + return res, failed +} + +// List implements Service.List with tracing. +func (mw OTelTracingMiddleware) List(ctx context.Context, filters ...backend.IFilter) ([]*cache.Item, error) { + ctx, span := mw.startSpan(ctx, "hypercache.List") + defer span.End() + + items, err := mw.next.List(ctx, filters...) + if err != nil { + span.RecordError(err) + } + + if items != nil { + span.SetAttributes(attribute.Int("items.count", len(items))) + } + + return items, err +} + +// Remove implements Service.Remove with tracing. +func (mw OTelTracingMiddleware) Remove(ctx context.Context, keys ...string) error { + ctx, span := mw.startSpan(ctx, "hypercache.Remove", attribute.Int("keys.count", len(keys))) + defer span.End() + + err := mw.next.Remove(ctx, keys...) + if err != nil { + span.RecordError(err) + } + + return err +} + +// Clear implements Service.Clear with tracing. +func (mw OTelTracingMiddleware) Clear(ctx context.Context) error { + ctx, span := mw.startSpan(ctx, "hypercache.Clear") + defer span.End() + + err := mw.next.Clear(ctx) + if err != nil { + span.RecordError(err) + } + + return err +} + +// Capacity returns cache capacity. +func (mw OTelTracingMiddleware) Capacity() int { return mw.next.Capacity() } + +// Allocation returns allocated size. +func (mw OTelTracingMiddleware) Allocation() int64 { return mw.next.Allocation() } + +// Count returns items count. +func (mw OTelTracingMiddleware) Count(ctx context.Context) int { return mw.next.Count(ctx) } + +// TriggerEviction triggers eviction with a span. +func (mw OTelTracingMiddleware) TriggerEviction() { + _, span := mw.startSpan(context.Background(), "hypercache.TriggerEviction") + defer span.End() + + mw.next.TriggerEviction() +} + +// Stop stops the service with a span. +func (mw OTelTracingMiddleware) Stop() { + _, span := mw.startSpan(context.Background(), "hypercache.Stop") + defer span.End() + + mw.next.Stop() +} + +// GetStats returns stats. +func (mw OTelTracingMiddleware) GetStats() stats.Stats { return mw.next.GetStats() } + +// startSpan starts a span with common and provided attributes. +func (mw OTelTracingMiddleware) startSpan(ctx context.Context, name string, attrs ...attribute.KeyValue) (context.Context, trace.Span) { + ctx, span := mw.tracer.Start(ctx, name, trace.WithSpanKind(trace.SpanKindInternal)) + if len(mw.commonAttrs) > 0 { + span.SetAttributes(mw.commonAttrs...) + } + + if len(attrs) > 0 { + span.SetAttributes(attrs...) + } + + return ctx, span +}