This document explains how to add a metric to a Substrate component: which instrument to pick, how to name it, which labels it may carry, how to write the Go, how to test it, and how to register it. It is the metrics counterpart of Tracing Best Practices.
Read Actor Observability first if you have not: it explains what already exists and how the registry is used. This document is about adding to it.
Add a metric when someone will aggregate the value: a rate, a ratio, a percentile, a count by outcome, a fleet total. A cache hit ratio, a per-phase latency distribution, and a queue depth are metrics.
Do not add a metric when the question is about one actor. Actor identity is
barred from metric labels (see Labels), so a metric
cannot answer "how long did actor X take". Write a structured log record with
ateattr.ActorLogAttrs instead; Restore timing breakdown in atelet is the
model. A metric and a log record are not alternatives: atelet emits both for a
restore, the histogram for the fleet view and the record for the per-actor view.
Do not add a metric for a one-time or startup fact (a config value, a version). Put it on the log line at startup, or on the resource.
Every metric PR has these parts. Reviewers check for each one.
- The instrument is defined in Go against an injected
metric.Meter, nil-safe, with ametric.WithUnitand ametric.WithDescription. - Every label key is a constant in
internal/ateattr, and every label value either comes from a bounded set defined there or names an operator-created object (a template, a pool), as the label rules allow. - A unit test collects the instrument through a
ManualReaderand asserts the name, the unit, the instrument kind, and the label set of each series. - The instrument and any new attribute are in
docs/metrics/registry/metrics.yaml, andhack/verify/metrics.shpasses. - If the metric has a rule the registry cannot hold, or a blind spot it closes,
docs/metrics/substrate.yamlis updated. - The metrics table in
docs/observability.mdhas a row for it.
| You want to know | Instrument | Go constructor | Example in tree |
|---|---|---|---|
| How many times something happened, and its rate | Counter | Int64Counter |
ate.imagecache.requests (internal/imagecache/metrics.go) |
| How long or how big each occurrence was, as a distribution | Histogram | Float64Histogram (seconds), Int64Histogram (bytes) |
ate.actor.restore.duration (cmd/atelet/metrics.go), atelet.snapshot.size (cmd/atelet/main.go) |
| How many things exist right now, in a tally you keep by adding and subtracting | UpDownCounter | Int64ObservableUpDownCounter (you can enumerate them at collection time), Int64UpDownCounter (you own the increments; rare) |
ate.workerpool.workers (cmd/ateapi/internal/controlapi/metrics.go) |
| A dial you read and write down, without adding or subtracting anything | Gauge | Int64ObservableGauge / Float64ObservableGauge |
ate.actor.stats.memory.working_set (cmd/atelet/statspoller.go) |
Rules of thumb:
- Prefer the observable form for "how many exist". An observable callback reads the truth (a cache, a map) at collection time, so a missed decrement cannot drift the value, and a group that disappears from the source disappears from the export. Use a synchronous UpDownCounter when the change is in hand on the request path and a series that keeps its last value after the final decrement is acceptable, as the router's parked-request count does; use the observable form when you would otherwise be mirroring a data structure you already own.
- UpDownCounter or gauge: are you counting, or reading a dial? An
UpDownCounter is a tally you keep yourself: something starts, you add one;
it ends, you subtract one.
ate.workerpool.workersis one because ateapi assigns and releases every worker, so it is the one keeping that tally. A gauge is a dial you read: memory working set is whatever the sandbox reports when atelet polls it (a cgroup on gVisor, the guest agent on micro-VM), written down per template. Ask "did I get this number by adding and subtracting, or by looking?" and the answer is the instrument. How a dashboard later aggregates the number does not enter into it. The type is a promise to the pipeline about what the datapoints are, and rollups and the actor relay act on that promise without checking. - Do not add a failure counter next to a success counter. One instrument,
with the failure on
error.type; the key's absence means success. See Reporting failures. - One histogram, many phases, rather than one histogram per phase, when
the phases share dimensions.
ate.actor.restore.durationcarriesate.snapshot.phaseas a label so the phases sit on one chart.
- Instrument names are dotted and lowercase:
ate.<subsystem>.<noun>for substrate-wide concepts (ate.actor.crashes,ate.imagecache.requests), or<component>.<subsystem>.<noun>when the metric is about the component's own mechanics (atenet.router.parking.active,atelet.snapshot.size). When in doubt,ate.*: a name that starts with a component ties the metric to that binary, and the same measurement from a second component then needs a second name. - Name the thing measured, not the aggregation.
duration,size,requests,workers. The unit lives in the instrument's unit field and each exporter renders it its own way: the Prometheus exporter on kind appends_seconds,_bytesand_total, while Cloud Monitoring keeps the OpenTelemetry name and appends only_bucket,_countand_sum(the shipped dashboards queryatelet.snapshot.size_bucket). Put no unit or aggregation in the name, or one backend will show it twice. - Use the upstream semantic-convention metric when one exists, with its name
(
rpc.server.call.durationcomes fromotelgrpcas is). When upstream has the shape but not the concept, mirror the shape under anate.*name:ate.workerpool.desired_workersandate.workerpool.ready_workersfollowk8s.deployment.desired_podsandk8s.deployment.available_pods, two instruments rather than one labeled by state, because desired plus ready is not a sum. - Follow the upstream attribute pattern when one exists:
*.operation.name,*.duration,error.type. Reuse an upstream attribute verbatim rather than aliasing it intoate.*(error.typeandfile.nameare used as is). - A label key that belongs to a subsystem is rooted at the subsystem
(
ate.imagecache.outcome), not underactor, when every actor shares the thing it describes.
Use UCUM units, the same ones the rest of the tree uses:
| Quantity | Unit | Go value |
|---|---|---|
| time | s |
time.Since(start).Seconds() as float64 |
| size | By |
int64 bytes |
| count of a thing | {thing} in braces: {request}, {worker}, {crash} |
int64 |
| ratio | 1 (UCUM; no instrument in tree uses it yet) |
float64 |
Record seconds, not milliseconds, even for fast paths: the buckets carry the resolution, and mixing units across instruments breaks every dashboard that divides one by another.
Every histogram sets explicit bucket boundaries. The SDK defaults (0, 5, 10, 25 … 10000) were chosen for milliseconds and are wrong for a value recorded in
seconds: everything between zero and five seconds lands in one bucket. Pick boundaries
that cover both ends of what you have seen, and write down in a comment which
ends those are. Reuse an existing set when the quantity is comparable:
| Quantity | Boundaries used in tree |
|---|---|
| a lifecycle operation | 0.005 … 30 (cmd/ateapi/internal/controlapi/metrics.go) |
| a scheduler step | 0.0005 … 5 (same file) |
| a snapshot phase | 0.005 … 60 (cmd/atelet/metrics.go, snapshotPhaseBuckets) |
| a request wait | 0.001 … 60 (cmd/atenet/internal/router/ingress/metrics.go) |
| a size in bytes | 1e6 … 1e10 (cmd/atelet/main.go) |
| a small count (workers) | 0, 1, 2, 3, 5, 10, 20, 50, 100, 250 (cmd/ateapi/internal/scheduling/metrics.go) |
The registry entry repeats the boundaries under annotations.substrate.buckets
so a reader can find them without the code.
The rules live in docs/metrics/substrate.yaml under cardinality_rules. The
ones every new metric meets:
- No actor identity.
ate.actor.name,ate.actor.uid,ate.atespace,ate.actor.versionandate.actor.container.nameare never metric labels. They belong on spans and log records. This is the rule that keeps the series count independent of how many actors have ever existed. - Bounded or catalog-scoped. Every
ate.*label is either an enumeration with a fixed value list, or names an object an operator created (a template, a pool). Nothing on a metric label comes from a request payload, an error message, a hostname, or a path. - Normalize at the producer. When a value arrives from the wire or from a
file nothing validated, map it onto the bounded set before recording:
ateattr.NormalizeSandboxClass,ateattr.NormalizeOperationName,ateattr.SnapshotFidelityValue. An unrecognized value reportsunknown, never the raw string. - Omit a label you do not know rather than emit it empty. A resume that
failed before a worker was picked has no pool; the pool keys are absent, not
"".ateattr.WorkerPoolAttributesreturns nil for that case. - Paired keys travel together.
ate.workerpool.namespacewithate.workerpool.name. Use theateattrhelpers that return the pair.
Every label key is a constant in internal/ateattr/ateattr.go, and every
bounded value set is a group of constants there beside it. A metric never
declares attribute.Key("...") locally; the one instrument that does, the
router's parking outcome label, predates the rule and is recorded under
lint_exceptions in substrate.yaml. Add the key and its values to ateattr
first; the registry entry and the code then agree by construction.
Before adding a label, ask what the dashboard groups by. A label nobody will group or filter by multiplies the series count for nothing. Three or four labels is typical; the restore histogram, with the most, has eight, and several are conditional.
The SDK enforces a hard limit: 2000 distinct attribute sets per instrument,
held per reader (OTEL_GO_X_CARDINALITY_LIMIT changes it; unset means 2000).
Past that, every new combination is folded into one series carrying only
otel.metric.overflow=true, silently. Multiply the value counts of your labels
and keep the product well under that. The limit applies inside one process, so
a per-template label on atelet is bounded by the templates one node hosts, not
the cluster.
One instrument carries success and failure. Success is the absence of the
failure key, error.type: at an RPC boundary it holds the gRPC status code
(status.Code(err).String()), as ate.actor.lifecycle.operation.duration
does. It also fits a bounded set of protocol statuses, as
ate.imagecache.requests does with an allow-list of HTTP codes and _OTHER
for everything else.
Separate a caller that gave up from a failure: context.Canceled and
context.DeadlineExceeded are their own outcomes (cancelled, timeout) on
the outcome label, not error. And record them: a cancelled pull still ran.
Pass the request context to Add and Record as it is, cancelled or not. The
SDK never checks ctx.Err(), so nothing is dropped; the only thing it reads
from the context is the span, so an exemplar can point at the sampled trace.
Put a package's instruments in one metrics.go holding: the instrument name
constants, a struct of instruments, a constructor that takes a metric.Meter,
and unexported record* methods that are nil-safe. internal/imagecache,
cmd/atelet and cmd/ateapi/internal/controlapi follow this shape.
const requestsMetric = "ate.snapshotcache.requests"
// Instruments holds the snapshot cache's instruments. A nil *Instruments is a
// valid no-op, so call sites need no guard.
type Instruments struct {
requests metric.Int64Counter
}
func NewInstruments(meter metric.Meter) (*Instruments, error) {
requests, err := meter.Int64Counter(
requestsMetric,
metric.WithUnit("{request}"),
metric.WithDescription("Number of snapshot lookups in the node-local snapshot cache, by outcome."),
)
if err != nil {
return nil, fmt.Errorf("create %s counter: %w", requestsMetric, err)
}
return &Instruments{requests: requests}, nil
}
// recordRequest counts one lookup. kind is known before the lookup starts, so
// the label is present on every outcome, which is what lets the registry mark
// it required. failureOutcome and errorType are the same two helpers as in
// internal/imagecache/metrics.go.
func (i *Instruments) recordRequest(ctx context.Context, kind, outcome string, err error) {
if i == nil || i.requests == nil {
return
}
if err != nil {
outcome = failureOutcome(err)
}
attrs := []attribute.KeyValue{
ateattr.SnapshotCacheOutcomeKey.String(outcome),
ateattr.SnapshotKindKey.String(kind),
}
if outcome == ateattr.SnapshotCacheOutcomeError {
attrs = append(attrs, ateattr.ErrorTypeKey.String(errorType(err)))
}
i.requests.Add(ctx, 1, metric.WithAttributes(attrs...))
}The nil-safety matters: tests, benchmarks and metric-free deployments construct the subsystem without instruments, and the call sites stay unconditional.
Every label the registry marks required has to be set on every path through
the record method. Here kind is a parameter rather than something derived
after the fact, so a miss, a hit and a failure all carry it. If a label is only
known on some paths, mark it conditionally_required in the registry and omit
it on the others; do not emit it empty.
Take the meter as a dependency; do not call otel.Meter deep inside a library
package. The binary's main passes otel.Meter("<component>") in, with the
component name as the scope ("atelet", "ateapi", "atecontroller"):
- A package with an options struct adds a
WithMeter(metric.Meter) Option(internal/imagecache). - A package constructed once in
maintakes a*Instrumentsbuilt there (cmd/atelet).
The meter provider itself is set up once per binary by
serverboot.InitMetrics (Prometheus reader plus OTLP push),
serverboot.InitMetricsBridged (OTLP push with a foreign Prometheus registry
bridged onto it, or that registry alone under OTEL_METRICS_EXPORTER=prometheus,
and whether to serve it; atecontroller), or serverboot.InitMetricsPushOnlyVia
(OTLP push only, over the atelet relay when given one; ateworker).
A new component calls one of these and defers ShutdownProvider; a new package
inside an existing component adds nothing there.
For a value you can enumerate at collection time, register a callback rather than tracking increments. This is the size instrument from the worked example:
const sizeMetric = "ate.snapshotcache.size"
// The cache owns its index and the bytes per kind partition it, so this is
// an UpDownCounter and not a gauge; observable, because the index is the
// truth to read at collection time.
size, err := meter.Int64ObservableUpDownCounter(sizeMetric,
metric.WithUnit("By"),
metric.WithDescription("Bytes of snapshots held in the node-local cache, by snapshot kind."))
...
_, err = meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
for kind, n := range cache.bytesByKind() {
o.ObserveInt64(size, n, metric.WithAttributes(ateattr.SnapshotKindKey.String(kind)))
}
return nil
}, size)Two habits from RegisterWorkerCount in ateapi: if the source is unavailable,
return nil and observe nothing this cycle rather than observing zero; and seed
the series an alert depends on with an explicit zero, so "no idle workers" is a
0 and not a missing series.
Keep the callback cheap and lock-free where you can. It runs on every collection (every 10 s on kind, every 60 s in production) and on every Prometheus scrape. The stats poller publishes a precomputed snapshot with an atomic pointer and the callback only reads it.
Test through a ManualReader; never through the global provider, which makes
tests order-dependent and blocks t.Parallel. The pattern in
cmd/atelet/metrics_test.go and internal/imagecache/metrics_test.go:
func newTestInstruments(t *testing.T) (*Instruments, *sdkmetric.ManualReader) {
t.Helper()
reader := sdkmetric.NewManualReader()
mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
inst, err := NewInstruments(mp.Meter("atelet"))
if err != nil {
t.Fatalf("NewInstruments: %v", err)
}
return inst, reader
}Then reader.Collect(ctx, &rm) and walk rm.ScopeMetrics[*].Metrics to find
the instrument by name. Assert:
- the name and unit (a typo here breaks every dashboard silently);
- the data type (
metricdata.Sum[int64]withIsMonotonictrue for a counter,metricdata.Histogram[float64]for a duration histogram, aGaugefor a gauge); - the label set of each series, including that a label is absent when it
should be (
error.typeon a success, the pool pair before assignment); - the value normalization: an unknown wire value lands as
unknown, a cancelled call lands ascancelledand noterror.
Test the behaviors that keep cardinality bounded, not just the happy path. The
image cache tests feed a registry error with an unlisted status and assert
_OTHER.
Once the metric exists on kind, the e2e metrics suite
(internal/e2e/suites/metrics) can assert it reached the collector by scraping
the collector's Prometheus endpoint (e2e.ScrapeCollectorMetrics). Add it to
the list the suite checks when it is a metric an operator will alert on.
The registry is an OpenTelemetry Weaver semantic convention registry and the only authoritative list of what substrate emits. Two kinds of entry:
An attribute, in an attribute_group for its subsystem, with every
permitted value as a member. Put a new group beside registry.ate.imagecache:
- id: registry.ate.snapshotcache
type: attribute_group
brief: >
The labels of the snapshot cache on the node. The key starts with the name
of the subsystem and not with actor: every actor on the node shares it.
attributes:
- id: ate.snapshotcache.outcome
stability: development
brief: >
The result of one snapshot lookup. Calculate the hit ratio as
hit / (hit + miss). Do not put the failures or the stopped lookups in
the denominator.
type:
members:
- id: hit
stability: development
value: hit
brief: The node holds the snapshot.
- id: miss
stability: development
value: miss
brief: The lookup must download the snapshot.
- id: error
stability: development
value: error
brief: The lookup failed. Only this outcome has an error.type key.
- id: cancelled
stability: development
value: cancelled
brief: The caller stopped the lookup.
- id: timeout
stability: development
value: timeout
brief: The time limit of the caller ended.A metric, in the section for its component:
- id: metric.ate.snapshotcache.requests
type: metric
metric_name: ate.snapshotcache.requests
instrument: counter
unit: "{request}"
stability: development
brief: The number of snapshot lookups in the snapshot cache on the node, by outcome.
note: >
A miss causes a download. Thus the hit ratio of a node is an early sign
of the resume time.
annotations:
substrate:
emitted_by: [atelet]
golden_signals: [latency, errors]
code_anchor: cmd/atelet/internal/snapshotcache/metrics.go
cuj: Resumes became slower on one node. Does the snapshot cache miss?
attributes:
- ref: ate.snapshotcache.outcome
requirement_level: required
- ref: ate.snapshot.kind
requirement_level: required
- ref: error.type
requirement_level:
conditionally_required: The outcome is error.The annotations.substrate block is substrate's own and Weaver passes it
through: emitted_by names the binaries, code_anchor the file that creates
the instrument, cuj the question an operator answers with it,
golden_signals which of the four golden signals it serves (latency,
traffic, errors, saturation; list every one that applies, so a dashboard
author can find the saturation instruments without reading each brief), and
buckets the histogram boundaries. Fill all applicable fields, including
buckets for a histogram; a reader of the registry should not need the code.
Attributes that already exist (ate.template.name, ate.snapshot.kind,
error.type) are referenced with ref:, not redefined.
Mark a label conditionally_required and say when, rather than required, if
the code omits it on some path.
Then run the check CI runs:
hack/verify/metrics.sh # local weaver only if it is exactly v0.25.1 (another version is an error), else the pinned image via dockerUpdate this file when the metric closes a listed blind_spot (remove the
entry), when it has a rule Weaver cannot express (add it under
cardinality_rules with what could enforce it), or when it deliberately
breaks a convention (add a lint_exceptions entry with the reason).
Add a row to the metrics table under "2. Metrics": name, emitting component, instrument kind, and one sentence on what it measures with the label list. If the label semantics need more than a sentence (as the snapshot labels did), add a short paragraph below the table rather than growing the row.
Cloud Monitoring dashboards live in tools/setup-gcp/dashboards/. A new metric
does not need a new dashboard in the same PR, but a metric an operator will
alert on should get a panel in a follow-up, and the PR description should say
which question the panel answers.
A cache in front of snapshot downloads, in atelet, wants to answer: is the cache helping, how much does a miss cost, and how much disk does it hold. That is four instruments, not one:
| Question | Instrument | Labels |
|---|---|---|
| Is it helping? | ate.snapshotcache.requests counter, by outcome |
ate.snapshotcache.outcome, ate.snapshot.kind, error.type on error |
| What does a miss cost? | ate.snapshotcache.fill.duration histogram, seconds, same boundaries as snapshotPhaseBuckets in cmd/atelet/metrics.go (unexported there, so copy or lift it) |
ate.snapshot.kind, ate.template.atespace, ate.template.name, failure pair on failure |
| How much does it hold? | ate.snapshotcache.size observable UpDownCounter, bytes (the cache owns the total and kind partitions it, so not a gauge), plus an ate.snapshotcache.evictions counter |
ate.snapshot.kind; evictions also carry an ate.snapshotcache.eviction.reason enum (capacity, ttl, explicit) |
What is deliberately not a label: the snapshot name or digest (one per
actor, unbounded), the object-storage URL (a path), the actor. The per-actor
"which snapshot did this actor's resume hit" question is a log record with
ateattr.ActorLogAttrs on the restore path, where Restore timing breakdown
already lives.
Steps, in order:
- In
internal/ateattr/ateattr.go:SnapshotCacheOutcomeKey,SnapshotCacheEvictionReasonKey, and their value constants. ReuseSnapshotKindKey,TemplateAtespaceKey,TemplateNameKey,ErrorTypeKey,FailureAttributes. cmd/atelet/internal/snapshotcache/metrics.go(a package one binary uses goes undercmd/<binary>/internal/): theInstrumentsstruct, constructor against ametric.Meter, nil-saferecord*methods, the size callback reading the cache's index under its existing lock.cmd/atelet/main.go: build the instruments withotel.Meter("atelet")and pass them into the cache.cmd/atelet/internal/snapshotcache/metrics_test.go:ManualReader; a miss-then-hit test, a failure test assertingerror.typeonly onerror, an eviction test per reason, a size test after an insert and an evict.docs/metrics/registry/metrics.yaml: theregistry.ate.snapshotcacheattribute group and the four metric entries;hack/verify/metrics.sh.docs/metrics/substrate.yaml: nothing to add. The size and eviction instruments ship with the cache, so it starts with no blind spot; theimage cache cost and evictionentry there is the image cache's gap, not this one's.docs/observability.md: four rows in the table, one paragraph on the outcome semantics.