Files
Jeff Luo 1da82f3e6a imagecache: add ate.imagecache.requests hit and miss telemetry (#831) (#834)
Add the `ate.imagecache.requests` counter.

  **`ate.imagecache.outcome`** (new key in `internal/ateattr`)

  | Value | Meaning |
  |---|---|
| `hit` | the node holds a complete image record: each layer directory
that the record names is present |
  | `miss` | the lookup must pull |
| `error` | the lookup failed; the only outcome that carries
`error.type` |
| `cancelled`, `timeout` | the caller gave up, so the cache is not at
fault |

A failed lookup is neither a hit nor a miss, so it gets its own outcome,
as
`no_free_worker` does on `ate.scheduler.outcome`. `cancelled` and
`timeout` are
outcomes for the same reason they are on `ate.router.outcome`. The hit
ratio is
therefore `hit / (hit + miss)`, with failures and abandoned lookups out
of the
  denominator.

  **`error.type`** — set only on the `error` outcome.

  | Value | Meaning |
  |---|---|
| `404`, `401`, `429`, ... | the registry rejected the request; its own
HTTP status, reported verbatim |
  | `_OTHER` | the failure carries no status of its own |

Fixes #831

> It's a good idea to open an issue first for discussion.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
2026-08-12 09:50:30 -04:00

101 lines
3.5 KiB
Go

// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package imagecache
import (
"context"
"errors"
"fmt"
"strconv"
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"github.com/agent-substrate/substrate/internal/ateattr"
)
const requestsMetric = "ate.imagecache.requests"
// errTypeOther is the OTel registry's fallback, used for a failure that
// carries no status of its own. It is spelled _OTHER, not the "unknown" of the
// ate.* labels, because error.type is that registry's attribute reused
// verbatim.
const errTypeOther = "_OTHER"
// reportedStatuses bounds the label: transport.Error carries whatever the
// remote returned, so a registry or a proxy could otherwise mint a new series
// per status. Each listed one has its own action: credentials (401, 403), the
// ref (404), throttling (429), a registry-side fault (5xx).
var reportedStatuses = map[int]bool{
401: true, 403: true, 404: true, 429: true,
500: true, 502: true, 503: true, 504: true,
}
// newRequestsCounter creates the ate.imagecache.requests instrument.
func newRequestsCounter(meter metric.Meter) (metric.Int64Counter, error) {
counter, err := meter.Int64Counter(
requestsMetric,
metric.WithUnit("{request}"),
metric.WithDescription("Number of image lookups in the node-local image cache, by outcome."),
)
if err != nil {
return nil, fmt.Errorf("create %s counter: %w", requestsMetric, err)
}
return counter, nil
}
// recordRequest counts one EnsureImage lookup. A store with no meter records
// nothing. A failure replaces the hit-or-miss outcome the caller passed, and
// only the Error outcome carries an error.type.
func (s *Store) recordRequest(ctx context.Context, outcome string, err error) {
if s.requests == nil {
return
}
if err != nil {
outcome = failureOutcome(err)
}
attrs := []attribute.KeyValue{ateattr.ImageCacheOutcomeKey.String(outcome)}
if outcome == ateattr.ImageCacheOutcomeError {
attrs = append(attrs, ateattr.ErrorTypeKey.String(errorType(err)))
}
// A cancelled lookup still reports: its pull was started and paid for.
s.requests.Add(context.WithoutCancel(ctx), 1, metric.WithAttributes(attrs...))
}
// failureOutcome separates a failed lookup from a caller that gave up.
// Cancellation is read first: an abandoned request also carries a transport
// error, which would otherwise read as a registry outage.
func failureOutcome(err error) string {
switch {
case errors.Is(err, context.Canceled):
return ateattr.ImageCacheOutcomeCancelled
case errors.Is(err, context.DeadlineExceeded):
return ateattr.ImageCacheOutcomeTimeout
}
return ateattr.ImageCacheOutcomeError
}
// errorType reports the registry's own status for its rejection, the only
// domain status this path has. Each other failure, and each status outside the
// reported set, carries none.
func errorType(err error) string {
var terr *transport.Error
if errors.As(err, &terr) && reportedStatuses[terr.StatusCode] {
return strconv.Itoa(terr.StatusCode)
}
return errTypeOther
}