Skip to main content

Querying CPU and RAM usage metrics over the API

The mStudio API exposes the CPU and memory usage statistics that you also see in the mStudio UI. Unlike the rest of the API, these are not served by regular REST operations: the /v2/apps/metrics route is a transparent proxy to a Grafana instance. Everything below that path is the Grafana HTTP API, so POST /v2/apps/metrics/api/ds/query is Grafana's data source query endpoint.

The data source behind it is a Prometheus instance, which means the actual queries you send are PromQL expressions.

Authentication

Authentication works exactly as for the rest of the API: pass your API token in the Authorization header.

POST /v2/apps/metrics/api/ds/query HTTP/1.1
Host: api.mittwald.de
Authorization: Bearer <YOUR_API_TOKEN>
Content-Type: application/json

See the API introduction for how to obtain an API token.

Authorization

The metrics proxy does not just authenticate your token, it also inspects the label selectors in your PromQL expression and authorizes them individually. There are two separate rules to be aware of, and they fail with two different errors.

Every query must be scoped

Each selector on a cloudhosting_project_* metric must carry at least one of the labels project, project_group or placement_group. An unscoped query such as SUM(cloudhosting_project_usage_memory_bytes:5m:max) is rejected outright, regardless of your permissions:

unauthorized: authorizer failed (one of the following authorizers must match:
[when metric matches '^cloudhosting_project_.*$', must have labels map[project:{}]],
[when metric matches '^cloudhosting_project_.*$', must have labels map[project_group:{}]],
[when metric matches '^cloudhosting_project_.*$', must have labels map[placement_group:{}]]):
none of the authorizers matched

You must have access to the resource you select

Selecting a resource that your token cannot access does not return an empty result — it returns an error for that query:

unauthorized: authorizer failed (must be authorized to access the resources
referenced in the query labels): user is not allowed to access project group s-XXXXXX

Crucially, access to a project does not imply access to its server. These are authorized separately, which has a practical consequence for the utilization examples below: they all divide by a cloudhosting_projectgroup_limits_* metric selected by project_group, so they require server-level access even when you only care about a single project.

If you only hold project-level access, you can still query absolute usage values, since those select by project alone:

SUM (cloudhosting_project_usage_memory_bytes:5m:max{project="p-XXXXXX"})

Note also that the returned series carry a project_group label even when you select by project only. Reading that label in a result is not the same as being allowed to select by it.

Available metrics

All metrics are recording rules, not raw series. The :5m suffix denotes the downsampling interval: values are pre-aggregated into five-minute buckets. Consequently, there is no point in querying at a resolution finer than five minutes — you will simply get the same bucket value repeated.

The suffix after the interval describes the aggregation function that was applied within each bucket (:max for peak usage). Metrics ending in _rate have already had rate() applied, so they must not be wrapped in rate() again.

Usage metrics

MetricLabelsUnitMeaning
cloudhosting_project_usage_memory_bytes:5m:maxproject, project_groupbytesPeak memory used by a project's workloads within each 5-minute bucket
cloudhosting_project_usage_cpu_seconds_total_rate:5mproject, project_groupCPU seconds/secondAverage CPU consumption of a project's workloads; 1 corresponds to one full core
cloudhosting_databasesetmember_usage_memory_bytes:5m:maxproject_groupbytesPeak memory used by the database instances (MySQL, Redis, …) of a server
cloudhosting_databasesetmember_usage_cpu_seconds_total_rate:5mproject_groupCPU seconds/secondAverage CPU consumption of the database instances of a server

Database usage is accounted for separately from project usage, because database instances are not part of a project's workloads. To get the total usage of a server, you therefore need to add both up (see the examples below).

Limit metrics

MetricLabelsUnitMeaning
cloudhosting_projectgroup_limits_memory_bytes:5mproject_groupbytesMemory available to the server
cloudhosting_projectgroup_limits_cpu_seconds:5mproject_groupCPU secondsCPU cores available to the server

Labels

  • project contains the short ID of a project, formatted as p-XXXXXX.
  • project_group contains the short ID of a server, formatted as s-XXXXXX. "Project group" is the internal name for what is called a server in the mStudio.

Note that these are the short IDs, not the UUID-formatted IDs that most REST operations of the API expect.

A third label, placement_group, is also accepted as a valid scope by the authorizer. It is not needed for any of the queries in this guide.

Result series additionally carry labels such as project_environment, cluster and tier. These are internal and should not be relied upon.

Example queries

The queries below all return a ratio between 0 and 1; multiply by 100 to get a percentage. This matches what the mStudio displays as utilization.

Memory utilization of a single project

SUM (cloudhosting_project_usage_memory_bytes:5m:max{project="p-XXXXXX"}) /
SUM (cloudhosting_projectgroup_limits_memory_bytes:5m{project_group="s-XXXXXX"})

This relates a single project's memory usage to the limit of the server it runs on — in other words, "how much of my server's RAM does this project consume?".

CPU utilization of a single project

SUM (cloudhosting_project_usage_cpu_seconds_total_rate:5m{project="p-XXXXXX"}) /
SUM (cloudhosting_projectgroup_limits_cpu_seconds:5m{project_group="s-XXXXXX"})

Memory utilization of an entire server

(SUM (cloudhosting_databasesetmember_usage_memory_bytes:5m:max{project_group="s-XXXXXX"} or vector (0)) +
SUM (cloudhosting_project_usage_memory_bytes:5m:max{project_group="s-XXXXXX"})) /
SUM (cloudhosting_projectgroup_limits_memory_bytes:5m{project_group="s-XXXXXX"})

Here, the project_group label is used on the usage metrics as well, so that all projects on the server are summed up, and the database usage is added on top.

The or vector (0) fallback is important: if a server has no databases at all, the cloudhosting_databasesetmember_* series does not exist, and without the fallback the entire addition would evaluate to an empty result rather than to the project usage alone.

CPU utilization of an entire server

(SUM (cloudhosting_databasesetmember_usage_cpu_seconds_total_rate:5m{project_group="s-XXXXXX"} or vector (0)) +
SUM (cloudhosting_project_usage_cpu_seconds_total_rate:5m{project_group="s-XXXXXX"})) /
SUM (cloudhosting_projectgroup_limits_cpu_seconds:5m{project_group="s-XXXXXX"})

Absolute values

If you are interested in absolute numbers instead of utilization ratios, simply drop the division:

SUM (cloudhosting_project_usage_memory_bytes:5m:max{project="p-XXXXXX"})

Or omit the SUM to get one series per project on a server, which is useful for breaking down which project on a server consumes the most resources:

cloudhosting_project_usage_memory_bytes:5m:max{project_group="s-XXXXXX"}

Sending a query

Queries are sent to the Grafana query endpoint at POST /v2/apps/metrics/api/ds/query. The request body follows Grafana's data source query API:

{
"from": "now-24h",
"to": "now",
"queries": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "MetricsAuthPrometheus"
},
"expr": "SUM (cloudhosting_project_usage_memory_bytes:5m:max{project=\"p-XXXXXX\"}) / SUM (cloudhosting_projectgroup_limits_memory_bytes:5m{project_group=\"s-XXXXXX\"})",
"intervalMs": 300000,
"maxDataPoints": 288
}
]
}

The relevant fields are:

  • from and to define the time range. They accept either epoch milliseconds or Grafana's relative time syntax (now, now-24h, now-7d).
  • datasource.uid must be MetricsAuthPrometheus. You can confirm this at runtime by calling GET /v2/apps/metrics/api/datasources, which lists the available data sources.
  • refId is an arbitrary identifier; the response is keyed by it. Send multiple entries in queries with distinct refIds to evaluate several expressions in a single round trip.
  • expr contains the PromQL expression.
  • intervalMs is the step size. Since the underlying data is downsampled to five minutes, 300000 is the most sensible value.
  • maxDataPoints caps the number of returned samples. If the requested range divided by intervalMs exceeds this value, Grafana increases the step size and you get coarser data than you asked for. To avoid that, set it to at least the number of buckets in your range — for the 24 hours at five-minute resolution used above, that is 24 × 60 / 5 = 288.

Interpreting the response

The response is a Grafana data frame, which stores values column-wise rather than as a list of timestamp/value pairs:

{
"results": {
"A": {
"frames": [
{
"schema": {
"refId": "A",
"fields": [
{ "name": "Time", "type": "time" },
{ "name": "Value", "type": "number" }
]
},
"data": {
"values": [
[1753048800000, 1753049100000, 1753049400000],
[0.4213, 0.4198, 0.4402]
]
}
}
]
}
}
}

data.values contains one array per field defined in schema.fields, in the same order. The first array holds the timestamps (epoch milliseconds), the second the values. To get the n-th sample, read index n from each array.

If a query fails, the corresponding results entry contains an error property instead of frames. Note that this may happen per query even when the HTTP status code is 200 — always check for error on each refId you sent.

Client libraries

None of the mittwald SDKs cover this endpoint, since it is not part of the OpenAPI specification. You have two practical options:

Use a plain HTTP client. The request and response shapes are small enough that this is often the least painful route. All you need is a POST with a JSON body and the two-array response decoding described above.

Use a Grafana client library for the data frame decoding. Useful ones:

@grafana/data provides dataFrameFromJSON(), which turns the raw response frames into typed DataFrame objects, plus helpers for iterating over fields. It is a large dependency, so this only pays off if you are working with the data extensively.

Since the proxy exposes the Grafana HTTP API unchanged, any generic Grafana client will work as long as you can configure a custom base URL and a bearer token.