Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# HyperCache

Check failure on line 1 in README.md

View check run for this annotation

Trunk.io / Trunk Check

prettier

Incorrect formatting, autoformat by running 'trunk fmt'

[![Go](https://github.com/hyp3rd/hypercache/actions/workflows/go.yml/badge.svg)][build-link] [![CodeQL](https://github.com/hyp3rd/hypercache/actions/workflows/codeql.yml/badge.svg)][codeql-link]

Expand Down Expand Up @@ -69,6 +69,24 @@

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:
Expand Down
2 changes: 2 additions & 0 deletions __examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
50 changes: 50 additions & 0 deletions __examples/observability/otel.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
12 changes: 12 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
150 changes: 150 additions & 0 deletions pkg/middleware/otel_metrics.go
Original file line number Diff line number Diff line change
@@ -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
Loading