Skip to content

Add metrics representing silo quota limits and utilization. - #11500

Merged
jmcarp merged 3 commits into
mainfrom
jmcarp/capacity-metrics
Oct 21, 2026
Merged

jmcarp merged 3 commits into
mainfrom
jmcarp/capacity-metrics

Conversation

@jmcarp

@jmcarp jmcarp commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

This patch adds two groups of metrics: silo_quota:{memory,cpu,disk}_capacity, which represents resource quota limits per silo; and silo_quota:{memory,cpu,disk}_provisioned, which represents resources charged against each quota.

We currently have two features that almost solve this problem, but aren't quite adequate: the collection_target metric, and the /v1/system/utilization/silos endpoint. The collection_target metric exposes quota utilization, mixing silos and projects. However, it's sparse, emitting values only when utilization changes, which doesn't play nicely with use cases that expect regular timeseries. It also doesn't expose quota limits. The silo utilization endpoint exposes quota limits, but isn't a metric.

We work around this problem in the OpenTelemetry metrics receiver, which can optionally emit synthetic capacity and utilization metrics by periodically querying Nexus. However, this requires users to run our otel collector, or implement their own synthetic metrics--neither of which is an ideal experience.

The metrics proposed here allow users to query silo quota capacity and utilization via OxQL or other metrics backends, without post-processing sparse metrics or deriving their own custom quota metrics.

Note: we collect these metrics using a new CapacityMetrics background task, which runs on each Nexus instance. As a result, we emit those metrics one per Nexus--in triplicate, by default. To handle that duplication, users should aggregate (max, mean) over nexus_id.

Part of oxidecomputer/customer-support#1383.

cc @smklein for quota context, @bnaecker for metrics context, @hermanol because we've had some support requests for this and related features. The new task is roughly modeled on InstanceWatcher, and hopefully isn't very interesting at a code level. I'd like to add some related metrics in the future, but wanted to establish a pattern for emitting capacity metrics first.

@jmcarp
jmcarp force-pushed the jmcarp/capacity-metrics branch from 2fac930 to 71ecb55 Compare October 15, 2026 01:55
@bnaecker

Copy link
Copy Markdown
Collaborator

I'm going to look at the implementation tomorrow, but I feel like the naming could use a little bikeshedding. Maybe something like silo_quota:{memory,cpu,disk}_{capacity,provisioned}? E.g., silo_quota:memory_capacity and silo_quota:memory_provisioned? I might be nitpicking, but the names were pretty confusing to me a first glance.

Comment thread dev-tools/omdb/tests/successes.out Outdated
configured period: every <REDACTED_DURATION>m
last completed activation: <REDACTED ITERATIONS>, triggered by <TRIGGERED_BY_REDACTED>
started at <REDACTED_TIMESTAMP> (<REDACTED DURATION>s ago) and ran for <REDACTED DURATION>ms
warning: unknown background task: "capacity_metrics" (don't know how to interpret details: Object {"samples_queued": Number(0)})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should probably add an implementation for this in omdb::nexus::print_task_details, rather than letting it fall through to the "warning: unknown background task..." behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, added.

@jmcarp

jmcarp commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

I might be nitpicking, but the names were pretty confusing to me a first glance.

Nitpicking is welcome! Conceptually, I was thinking of a set of quota metrics capturing:

  • silo | project
  • capacity | provisioned
  • memory | cpu | disk

I haven't added project-scoped metrics here, in the interest of keeping the patch small, but I'd like to get to that in a follow-up. So the full set of metric names under the proposed scheme would eventually look like:

  • quota_capacity_silo:{memory,cpu,disk}
  • quota_provisioned_silo:{memory,cpu,disk}
  • quota_provisioned_project:{memory,cpu,disk}

So what you're proposing would probably look like:

  • silo_quota:{memory,cpu,disk}_{capacity,provisioned}
  • project_quota:{memory,cpu,disk}_provisioned

I've been staring at these names for too long, but I think yours make sense, and it feels more correct to capture capacity/provisioned in the metric name instead of the target name. I had started all the names with quota_ so that quota-related metrics would sort together, but quota_silo sounds kind of silly. Anyway, I'm happy to switch to the naming pattern you're suggesting--I'll just do that unless anybody has better ideas or wants to discuss more.

This patch adds two groups of metrics: silo_quota:{memory,cpu,disk}_capacity,
which represents resource quota limits per silo; and
silo_quota:{memory,cpu,disk}_provisioned, which represents resources charged
against each quota.

We currently have two features that almost solve this problem, but aren't quite
adequate: the `collection_target` metric, and the /v1/system/utilization/silos
endpoint. The `collection_target` metric exposes quota utilization, mixing
silos and projects. However, it's sparse, emitting values only when utilization
changes, which doesn't play nicely with use cases that expect regular
timeseries. It also doesn't expose quota limits. The silo utilization endpoint
exposes quota limits, but isn't a metric.

We work around this problem in the [OpenTelemetry metrics
receiver](https://github.com/oxidecomputer/opentelemetry-collector-components/tree/main/receiver/oxidemetricsreceiver),
which can optionally emit synthetic capacity and utilization metrics by
periodically querying Nexus. However, this requires users to run our otel
collector, or implement their own synthetic metrics--neither of which is an
ideal experience.

The metrics proposed here allow users to query silo quota capacity and
utilization via OxQL or other metrics backends, without post-processing sparse
metrics or deriving their own custom quota metrics.

Note: we collect these metrics using a new `CapacityMetrics` background task,
which runs on each Nexus instance. As a result, we emit those metrics one per
Nexus--in triplicate, by default. To handle that duplication, users should
aggregate (max, mean) over `nexus_id`.

Part of oxidecomputer/customer-support#1383.
@jmcarp
jmcarp force-pushed the jmcarp/capacity-metrics branch from 71ecb55 to 133c4ad Compare October 15, 2026 16:04
@jmcarp

jmcarp commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

Anyway, I'm happy to switch to the naming pattern you're suggesting

Did this--and sorry, I force-pushed out of habit, although I also wanted to change the commit message so that it would get the new names right.

@bnaecker bnaecker left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, this is great. I have a few comments, though overall looks solid to me.

)
.await?;

let mut metrics = self.metrics.lock().unwrap();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can move this lock to after the loop. Compute all the metrics, lock once, and move them all onto the queue, then drop the lock.

description = "Silo quota capacity and provisioned resources"
authz_scope = "fleet"
versions = [
{ version = 1, fields = [ "nexus_id", "silo_id" ] },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about putting the nexus_id field on the timeseries. On the one hand, I get it that it's useful to dedupe the data. OTOH, it doesn't really provide any useful context for the data itself.

I think we have a few options:

  • Leave it
  • Remove it, relying on the timestamps themselves for deduplication. E.g, use something like mean_within() for alignment or the equivalent in a 3p tool / language.
  • Remove it, and do something complicated to try to avoid reporting values from different Nexus "close by" in time.

I'm inclined toward the 2nd option, but WDYT?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Remove it, relying on the timestamps themselves for deduplication. E.g, use something like mean_within() for alignment or the equivalent in a 3p tool / language.

IIUC some external metrics systems (like Prometheus) get Very Sad when we have multiple data points in one timeseries with no labels to disambiguate them. I guess here, that would only happen if two nexuses HAPPENED to emit such a data point with exactly the same timestamp, which seems unlikely, but...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some external metrics systems (like Prometheus) get Very Sad when we have multiple data points in one timeseries with no labels to disambiguate them.

Yes--for example, oxidecomputer/hubris#2634. Prometheus also represents timestamps in ms, not ns as in oximeter, so collisions are more likely there (although still not at all likely, and users who want to export to prometheus-like stores can deduplicate on their end if necessary).

The robots also helpfully pointed out that the oximeter read path can panic on duplicate timestamps--although again, with nanosecond precision, this is extremely unlikely, and I believe we can fix that by changing a > 0 to >= 0, and it's not an architectural assumption in oximeter.

Anyway, I have a slight preference for keeping the nexus_id label, both because it avoids potential sadness in downstream metrics stores related to duplicate samples, and because--and I know this is a weak argument--labeling by nexus zone gives us n regularly-spaced series per silo, and omitting the label gives us a single series with weird and variable intervals between samples. I don't think either of these is particularly strong, which is why I'm calling it a slight preference, and I'm happy to drop that label if others prefer.

description = "ID of the silo"

[[metrics]]
name = "memory_capacity"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a difference between "capacity" and "quota"? Should we use the same term in the name and description?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we have a few ways of describing these quantities already:

  • In /v1/utilization, we say capacity for the amount of resource available to be provisioned, and provisioned for the amount actually provisioned.
  • In /v1/system/utilization/silos, we use allocated and provisioned, respectively.
  • In /v1/system/silo-quotas, we just use an object of cpus, memory, storage, without referring to quota, capacity, etc.

My thinking here was to try and stay consistent with existing APIs, although they're not entirely consistent as they are. I personally prefer capacity/provisioned to allocated/provisioned, so that's what I used to begin with. I think quota makes sense as well, but I think it's less consistent with existing APIs, and arguably that would mean overloading quota to mean both the resource defined in /v1/system/silo-quotas, as well as each per-resource limit within that resource. So I think I'd lean toward leaving the metric names as they are, and changing the descriptions to match.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me. I was mostly asking about the different terms in the names and descriptions. I agree the rest of the API is a little confused, but it's good this is all self-consistent.


[[metrics]]
name = "disk_provisioned"
description = "Provisioned disk charged against the quota"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this also include the snapshot usage? The description for disk_capacity makes it seem like it should, which we should make clear here too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, fixing this. I'm also realizing that I should have called these metrics storage and not disk to match existing APIs, so I'll change that too.

Comment on lines +11 to +14
//! Note: because background tasks run on each Nexus instance, these metrics are
//! emitted once for each Nexus--as of this writing, in triplicate. Callers
//! should take the mean or maximum datum value over Nexus instance IDs to
//! deduplicate.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These instructions seem like they're intended for someone consuming the metrics through the API, but they aren't going to be visible in customer docs as written. Should this advice exist somewhere else?

Without this, it seems possible that the values will be jittery, as each Nexus races to insert values?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, adding a note in silo-quota.toml.

//! deduplicate.
//!
//! Note: the `silo_quota` metrics are related to, but distinct from, the
//! `collection_target` metric. The `silo_quota` metrics are emitted on a

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the cost of fine-pointed accuracy here - we COULD make the collection_target metric a background polling task too, if it would be more useful to have regularly-emitted values. I'm not asking for that change in this PR, but rather, offering that it seems fine to me to change that behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I'll make the comment more precise. In some sense, we could also think about turning collection_target:* into the new proposed metrics: sample it on an interval in a background task, add capacity, separate silo collections from project collections--but at least while oximeter metric labels are immutable, it seems easier to add a new metric than to reshape the existing one.


let silo_utils = self
.datastore
.silo_utilization_list(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This filters out the default silo + non-discoverable silos, as an FYI. Is that intentional?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's intentional, but not necessarily correct! My thought was to match /v1/system/utilization/silos. It sounds like the default silo concept is meant to be retired, so I didn't think too hard about it. And it does look like non-discoverable silos will be returned here, if they have non-zero quota capacity:

        .filter(
            dsl::silo_discoverable
                .eq(true)
                .or(dsl::cpus_allocated.gt(0))
                .or(dsl::memory_allocated.gt(0))
                .or(dsl::storage_allocated.gt(0)),
        )
        .filter(dsl::silo_id.ne(DEFAULT_SILO_ID))

My weakly-held opinion here is that the new metric should match /v1/system/utilization/silos, and I'm agnostic about whether we should show non-discoverable silos without quotas set--but I would lean toward showing those silos for both views, or neither.

.datastore
.silo_utilization_list(
opctx,
&PaginatedBy::Id(DataPageParams::max_page()),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to be using max_page, rather than paginating? This could make very expensive queries if we have a lot of silos

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switching to pagination.

now,
&target,
&silo_quota::CpuCapacity {
datum: util.cpus_allocated.cast_unsigned(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that cast_unsigned would convert a negative value into an extremely large value - would it be better to clamp this at zero?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, fixing.


/// Create a project in the given silo.
async fn project_create_in_silo(
pub async fn project_create_in_silo(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know you're making this pub for a test, but it's doing the same thing as project_create, just skipping an authz lookup.

This seems a little sketchy to expose as pub - is it really not possible to use project_create in that test?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverting. I wound up creating a test user and giving it silo access, which is boilerplate I was trying to avoid, but seems better than changing method access just for the test.


impl Metrics {
pub(crate) fn push_samples(&mut self, samples: Vec<Sample>) {
self.samples.extend(samples);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might want to look into oximeter::queue::BoundedQueue. Otherwise, if oximeter goes down, this will grow indefinitely

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to a BoundedQueue<Sample>.

Comment thread dev-tools/omdb/src/bin/omdb/nexus.rs Outdated
Comment on lines +2247 to +2248
"warning: failed to interpret task details: {:?}: {:?}",
error, details

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

turbo nit: perhaps:

Suggested change
"warning: failed to interpret task details: {:?}: {:?}",
error, details
"warning: failed to interpret task details: {error:?}: {details:?}",

async move {
match self.fetch_samples(opctx, self.nexus_id).await {
Ok(sample_count) => {
serde_json::json!({"samples_queued": sample_count})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a nitpick, and it's not a huge deal here because the status object only has one field in it, but it's worth noting that a lot of the other background tasks have a single Rust type defined in nexus_types::internal_api::background that is used both when serializing the status object in Nexus and when deserializing it in omdb. We could do that here, and it might be a little bit nicer --- though that being nicer would matter a bit more if there was more data here...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Maybe we'll add more data later--I do want to add more metrics here once we agree on the basics.

description = "Silo quota capacity and provisioned resources"
authz_scope = "fleet"
versions = [
{ version = 1, fields = [ "nexus_id", "silo_id" ] },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Remove it, relying on the timestamps themselves for deduplication. E.g, use something like mean_within() for alignment or the equivalent in a 3p tool / language.

IIUC some external metrics systems (like Prometheus) get Very Sad when we have multiple data points in one timeseries with no labels to disambiguate them. I guess here, that would only happen if two nexuses HAPPENED to emit such a data point with exactly the same timestamp, which seems unlikely, but...

* Use consistent-ish names and descriptions throughout.
* Use `BoundedQueue` to prevent unbounded sample growth.
* Paginate utilization records.
* Fix other nits from code review.
@jmcarp
jmcarp force-pushed the jmcarp/capacity-metrics branch from 1d50e0b to 0bc1a69 Compare October 16, 2026 15:52
@jmcarp

jmcarp commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Ready for another look--I think everything is addressed. I think the only open question is about whether or not to include the nexus_id label, because I can see the arguments either way, and we may not have consensus. Although I'm tentatively proposing that we keep it for reasons given above.

@jmcarp

jmcarp commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

I chatted with @lgfa29 about this, and continue to think including the nexus_id label is less bad than omitting it. At least until we implement metrics schema versioning, we won't be able to add labels to these metrics after they make it into a release (unless we use a workaround like a silo_quota_v2). So if we omit the label and later discover that duplicate or near-duplicate samples cause problems in a user's tsdb, we wouldn't have any great options available. Maybe we'll make a change later on that makes the label less relevant--we could include a best-effort deduplication via lease, as in WebhookDeliverator, or move scheduled control plane metrics to a separate singleton service--in which case nexus_id would reduce to a label with cardinality of (almost always) one, but wouldn't cause any harm.

Anyway, let me know if the above is unconvincing, or if the patch needs other changes.

@jmcarp

jmcarp commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

By the way, a quick sanity check that the new metrics work. Thanks @fakemonster for the new metrics explorer!

Screenshot 2026-10-20 at 12 32 46 PM Screenshot 2026-10-20 at 12 32 59 PM

]

[[metrics]]
name = "storage_capacity"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you're asking for naming nitpicking: One thing I'm realizing is that the usage of "storage" here refers to "virtual storage", not "physical storage". If we add metrics for "physical storage" later, it'll be hard to detangle these two.

As an example: Crucible storage being distributed, so asking for "1 GiB virtual" actually uses ~3.75 GiB physical storage.

Do we want to label this virtual_storage_capacity, or otherwise have some way of making clear that it isn't physical?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think this is right, especially given we're talking about changing and/or adding storage quotas and quota metrics in https://rfd.shared.oxide.computer/rfd/0624. I'm going to rename the storage metrics, then merge.

@jmcarp
jmcarp enabled auto-merge (squash) October 21, 2026 15:45
@jmcarp
jmcarp merged commit cb12d33 into main Oct 21, 2026
19 checks passed
@jmcarp
jmcarp deleted the jmcarp/capacity-metrics branch October 21, 2026 16:42
jmcarp added a commit that referenced this pull request Oct 23, 2026
Analogous to #11500, but covering projects rather than silos. Project have
utilization but not capacity, so we only emit utilization metrics.

Note: to parallel the existing `DataStore::silo_utilization_list`, and the
`silo_utilization` view that it queries, we add a
`DataStore::project_utilization_list` method and `project_utilization` view. We
don't add a `/v1/system/utilization/projects` endpoint to parallel
`/v1/system/utilization/silos`, but we could in a future patch if useful.

Part of oxidecomputer/customer-support#1383.
jmcarp added a commit that referenced this pull request Oct 26, 2026
Analogous to #11500, but covering projects rather than silos. Project have
utilization but not capacity, so we only emit utilization metrics.

Note: to parallel the existing `DataStore::silo_utilization_list`, and the
`silo_utilization` view that it queries, we add a
`DataStore::project_utilization_list` method and `project_utilization` view. We
don't add a `/v1/system/utilization/projects` endpoint to parallel
`/v1/system/utilization/silos`, but we could in a future patch if useful.

Part of oxidecomputer/customer-support#1383.
jmcarp added a commit that referenced this pull request Oct 28, 2026
Analogous to #11500, but covering projects rather than silos. Project have
utilization but not capacity, so we only emit utilization metrics.

Note: to parallel the existing `DataStore::silo_utilization_list`, and the
`silo_utilization` view that it queries, we add a
`DataStore::project_utilization_list` method and `project_utilization` view. We
don't add a `/v1/system/utilization/projects` endpoint to parallel
`/v1/system/utilization/silos`, but we could in a future patch if useful.

Part of oxidecomputer/customer-support#1383.
jmcarp added a commit that referenced this pull request Oct 29, 2026
Analogous to #11500, but covering projects rather than silos. Project have
utilization but not capacity, so we only emit utilization metrics.

Note: to parallel the existing `DataStore::silo_utilization_list`, and the
`silo_utilization` view that it queries, we add a
`DataStore::project_utilization_list` method and `project_utilization` view. We
don't add a `/v1/system/utilization/projects` endpoint to parallel
`/v1/system/utilization/silos`, but we could in a future patch if useful.

Part of oxidecomputer/customer-support#1383.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants