-
Notifications
You must be signed in to change notification settings - Fork 131
Instrument both workercache and valkey store with metrics. #347
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,6 +56,9 @@ import ( | |
| "github.com/agent-substrate/substrate/cmd/ateapi/internal/store" | ||
| "github.com/agent-substrate/substrate/pkg/proto/ateapipb" | ||
| "github.com/redis/go-redis/v9" | ||
| "go.opentelemetry.io/otel" | ||
| "go.opentelemetry.io/otel/attribute" | ||
| "go.opentelemetry.io/otel/metric" | ||
| "google.golang.org/protobuf/encoding/protojson" | ||
| "google.golang.org/protobuf/proto" | ||
| ) | ||
|
|
@@ -74,16 +77,25 @@ type redisClient interface { | |
|
|
||
| // Persistence is a service that stores information about applications in Redis. | ||
| type Persistence struct { | ||
| rdb redisClient | ||
| rdb redisClient | ||
| metricWorkerPubsubMessages metric.Int64Counter | ||
| } | ||
|
|
||
| var _ store.Interface = (*Persistence)(nil) | ||
|
|
||
| // NewPersistence creates a new Persistence. | ||
| func NewPersistence(redisClient *redis.ClusterClient) *Persistence { | ||
| return &Persistence{ | ||
| rdb: redisClient, | ||
| func NewPersistence(redisClient *redis.ClusterClient) (*Persistence, error) { | ||
| publishMessages, err := otel.Meter("ateredis").Int64Counter( | ||
| "persistence.worker.pubsub.messages", | ||
| metric.WithUnit("{message}"), | ||
| metric.WithDescription("Total worker-changes pub/sub PUBLISH attempts.")) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("create persistence.worker.pubsub.messages counter: %w", err) | ||
| } | ||
| return &Persistence{ | ||
| rdb: redisClient, | ||
| metricWorkerPubsubMessages: publishMessages, | ||
| }, nil | ||
| } | ||
|
|
||
| func actorDBKey(id string) string { | ||
|
|
@@ -128,6 +140,9 @@ func (s *Persistence) publishWorkerEvent(ctx context.Context, eventType store.Wo | |
| } | ||
| if err := s.rdb.Publish(ctx, workerPubSubChannel, payload).Err(); err != nil { | ||
| slog.ErrorContext(ctx, "worker event publish failed", slog.Any("err", err)) | ||
| s.metricWorkerPubsubMessages.Add(ctx, 1, metric.WithAttributes(attribute.String("error.type", "_OTHER"))) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm a little confused on this. Is it a typical pattern to increment the same metric with an error attribute, or have a separate metric for failures?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not an otel expert, but I think this is the recommended way of reporting errors (https://opentelemetry.io/docs/specs/semconv/general/recording-errors/#recording-errors-on-metrics).
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Putting
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: always |
||
| } else { | ||
| s.metricWorkerPubsubMessages.Add(ctx, 1) | ||
| } | ||
| } | ||
|
|
||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What do you think about also covering |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,13 +28,13 @@ import ( | |
|
|
||
| "github.com/agent-substrate/substrate/cmd/ateapi/internal/store" | ||
| "github.com/agent-substrate/substrate/pkg/proto/ateapipb" | ||
| "go.opentelemetry.io/otel" | ||
| "go.opentelemetry.io/otel/attribute" | ||
| "go.opentelemetry.io/otel/metric" | ||
| "k8s.io/apimachinery/pkg/util/wait" | ||
| ) | ||
|
|
||
| // Cache maintains an in-memory snapshot of all workers. | ||
| // | ||
| // TODO: add metrics — at minimum a gauge for worker count, a counter for | ||
| // resync events, and a counter for failed PUBLISH operations (in ateredis). | ||
| type Cache struct { | ||
| store store.Interface | ||
| relistInterval time.Duration | ||
|
|
@@ -43,17 +43,53 @@ type Cache struct { | |
| workers map[string]*ateapipb.Worker | ||
|
|
||
| ready atomic.Bool | ||
|
|
||
| metricWorkerCount metric.Int64Gauge | ||
| metricNotReadyDuration metric.Float64Counter | ||
| metricResyncs metric.Int64Counter | ||
| metricRelists metric.Int64Counter | ||
| } | ||
|
|
||
| // New creates a Cache backed by a given store. relistInterval controls how | ||
| // often the cache performs a full ListWorkers to recover from state drifts | ||
| // caused by missing WorkerWatch events. | ||
| func New(store store.Interface, relistInterval time.Duration) *Cache { | ||
| return &Cache{ | ||
| store: store, | ||
| func New(s store.Interface, relistInterval time.Duration) (*Cache, error) { | ||
| c := &Cache{ | ||
| store: s, | ||
| relistInterval: relistInterval, | ||
| workers: make(map[string]*ateapipb.Worker), | ||
| } | ||
| m := otel.Meter("workercache") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: it could be better to create a helper method for metric, and should we move this to |
||
| var err error | ||
| c.metricWorkerCount, err = m.Int64Gauge( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we make this be an |
||
| "cache.worker.count", | ||
| metric.WithUnit("{worker}"), | ||
| metric.WithDescription("Current number of workers in the cache.")) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("create cache.worker.count gauge failed: %w", err) | ||
| } | ||
| c.metricNotReadyDuration, err = m.Float64Counter( | ||
| "cache.not_ready.duration", | ||
| metric.WithUnit("s"), | ||
| metric.WithDescription("Total time the worker cache spent not ready, between a watch disconnect and a successful resync.")) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("create cache.not_ready.duration counter failed: %w", err) | ||
| } | ||
| c.metricResyncs, err = m.Int64Counter( | ||
| "cache.resyncs", | ||
| metric.WithUnit("{resync}"), | ||
| metric.WithDescription("Total full resyncs triggered by watch disconnects.")) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("create cache.resyncs counter failed: %w", err) | ||
| } | ||
| c.metricRelists, err = m.Int64Counter( | ||
| "cache.relists", | ||
| metric.WithUnit("{relist}"), | ||
| metric.WithDescription("Total relists (initial, resync, and periodic).")) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("create cache.relists counter failed: %w", err) | ||
| } | ||
| return c, nil | ||
| } | ||
|
|
||
| // Start syncs the cache synchronously, then spawns a background goroutine | ||
|
|
@@ -101,6 +137,7 @@ func (c *Cache) sync(ctx context.Context) (*store.WorkerWatch, error) { | |
| func (c *Cache) relist(ctx context.Context) error { | ||
| workers, err := c.store.ListWorkers(ctx) | ||
| if err != nil { | ||
| c.metricRelists.Add(ctx, 1, metric.WithAttributes(attribute.String("error.type", "_OTHER"))) | ||
| return fmt.Errorf("ListWorkers: %w", err) | ||
| } | ||
| newMap := make(map[string]*ateapipb.Worker, len(workers)) | ||
|
|
@@ -109,8 +146,11 @@ func (c *Cache) relist(ctx context.Context) error { | |
| } | ||
| c.mu.Lock() | ||
| c.workers = newMap | ||
| count := int64(len(newMap)) | ||
| c.mu.Unlock() | ||
| slog.InfoContext(ctx, "worker cache synced", slog.Int("count", len(newMap))) | ||
| c.metricWorkerCount.Record(ctx, count) | ||
| c.metricRelists.Add(ctx, 1) | ||
| slog.InfoContext(ctx, "worker cache synced", slog.Int("count", int(count))) | ||
| return nil | ||
| } | ||
|
|
||
|
|
@@ -122,6 +162,7 @@ func (c *Cache) watchEvents(ctx context.Context, watch *store.WorkerWatch) { | |
| case event, ok := <-watch.Events: | ||
| if !ok { | ||
| c.ready.Store(false) | ||
| notReadySince := time.Now() | ||
| watch.Close() | ||
| if ctx.Err() != nil { | ||
| return | ||
|
|
@@ -132,8 +173,9 @@ func (c *Cache) watchEvents(ctx context.Context, watch *store.WorkerWatch) { | |
| return // context cancelled | ||
| } | ||
| c.ready.Store(true) | ||
| c.metricNotReadyDuration.Add(ctx, time.Since(notReadySince).Seconds()) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems to fire only after resync succeeds, so you can't alert on "cache been not ready for 2+ minutes" while it's actually happening, which is kinda the whole point, right? Also, if the pod gets oomkilled, we never record the duration. Could we also expose readiness as a gauge? |
||
| } else { | ||
| c.applyEvent(event) | ||
| c.applyEvent(ctx, event) | ||
| } | ||
| case <-ticker.C: | ||
| if err := c.relist(ctx); err != nil { | ||
|
|
@@ -148,6 +190,7 @@ func (c *Cache) watchEvents(ctx context.Context, watch *store.WorkerWatch) { | |
| } | ||
|
|
||
| func (c *Cache) resync(ctx context.Context) *store.WorkerWatch { | ||
| c.metricResyncs.Add(ctx, 1) | ||
| backoff := wait.Backoff{ | ||
| Duration: time.Second, | ||
| Factor: 2.0, | ||
|
|
@@ -167,10 +210,9 @@ func (c *Cache) resync(ctx context.Context) *store.WorkerWatch { | |
| return watch | ||
| } | ||
|
|
||
| func (c *Cache) applyEvent(event store.WorkerEvent) { | ||
| func (c *Cache) applyEvent(ctx context.Context, event store.WorkerEvent) { | ||
| key := workerKey(event.Worker) | ||
| c.mu.Lock() | ||
| defer c.mu.Unlock() | ||
| switch event.Type { | ||
| case store.WorkerEventDeleted: | ||
| delete(c.workers, key) | ||
|
|
@@ -180,6 +222,9 @@ func (c *Cache) applyEvent(event store.WorkerEvent) { | |
| c.workers[key] = event.Worker | ||
| } | ||
| } | ||
| count := int64(len(c.workers)) | ||
| c.mu.Unlock() | ||
| c.metricWorkerCount.Record(ctx, count) | ||
| } | ||
|
|
||
| func workerKey(w *ateapipb.Worker) string { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a way to do
testing.Deferor something similar so we don't need to remember to do this every time?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That sounds like a good idea. We are already using this pattern throughout this file so I would probably tackle that as a separate PR?