Runner Level Metrics for GitHub Actions Jobs

GitHub Actions reports job duration and nothing about the machine. Read per instance CPU, memory, disk and queue metrics in WarpBuild, then right size.

Last verified:

GitHub Actions tells you how long a job took and nothing about what the machine was doing while it ran, so CPU and memory numbers have to come from an agent on the runner. WarpBuild runs that agent on every runner it operates, collects CPU, memory, filesystem, and network utilization over OpenTelemetry, and rolls the results up per repository, workflow, job, and instance type so you can point at the resource that is actually holding the job back.

This guide covers what job-level timing hides, how to read the per instance metrics in the Observability Usage view and the right sizing suggestions in the Recommendations view, how to pull the same numbers from the API, and how to price a runner size change before you make it.

Diagnosis

A GitHub Actions job that takes 18 minutes gives you one number. That number is the sum of at least four separate things, and each one has a different fix.

CPU saturation. The job is core bound. Sustained CPU sits near 100 percent for most of the run, the compiler or the test runner is already using every core, and the only lever that moves the clock is more cores. This is the case where a bigger runner size actually returns time.

Memory pressure. The job is allocating close to the ceiling. Symptoms show up as test workers dying without a stack trace, a linker that gets killed, or a build that slows down long before it fails while the kernel reclaims pages. Peak memory utilization near 100 percent explains failures that look random in the GitHub Actions log.

Disk throughput limits. A job can look idle on CPU while a large checkout, a Docker layer export, or a cache restore saturates disk. Read plus write throughput is recorded separately from CPU for exactly this shape, because the log line that takes four minutes gives no hint that the bottleneck is the volume.

Queue wait. Time spent waiting for a runner is counted separately from execution. A pipeline whose end to end time grew while duration p90 stayed flat grew in the queue. Queue timings are reported per runner label and per stack, and the daily chart splits total queue time into GitHub time and WarpBuild time so you can tell which side of the handoff moved.

Metrics are aggregated in a hierarchy: repository, then workflow, then job, then instance type. That ordering matters when you are hunting. Start at the repository to find the workflow burning the most machine time, drill to the job, then look at the instance type the job actually landed on, because a matrix job frequently runs on a size nobody chose deliberately.

Five metrics are recorded per runner instance.

MetricWhat the agent records
CPU utilizationMaximum rolling average CPU usage percentage over the last 30 seconds
Memory utilizationMaximum memory usage percentage
Filesystem utilizationMaximum storage usage percentage
Disk I/OMaximum rolling average of read plus write disk throughput over the last 30 seconds
Network utilizationMaximum rolling average of read plus write network throughput over the last 30 seconds

The rolling average matters when you read the CPU number. A single 3 second spike during a compile burst does not push the sustained figure up, so a job showing 40 percent max sustained CPU has real headroom even if its flame chart looks busy. Two limits are worth knowing before you go looking: Observability collects metrics only for jobs longer than about one minute, and collection can be paused, which stops system logs and GitHub Actions logs along with the metrics.

Fix

The workflow is the same every time. Find the outliers in the Jobs report, confirm the shape in the Usage view, then change the label.

Step 1: rank jobs by resource, not by duration. The Jobs report aggregates per unique repository, workflow, and job name combination, and gives you run count, success rate, and p75 and p90 for duration, queue time, CPU, and memory. Sorting by cpu_p90 descending surfaces the jobs that are pinned. Sorting ascending surfaces the jobs paying for cores they never touch.

Step 2: read the Recommendations view. Recommendations applies fixed thresholds to the utilization data and labels the instances that cross them.

MetricThresholdAlert label
Max sustained CPU80 percent or higherHigh CPU Usage
Max memory utilization80 percent or higherHigh Memory Usage
Max filesystem utilization80 percent or higherHigh Filesystem Usage
Max disk I/O80 percent or higher of supported throughputHigh Disk IO

Each recommendation covers the organization's last 7 days and carries the job's repository, workflow, and job name, a recommendation type of upgrade or downgrade, the current label with its vCPU and memory, the recommended label with its vCPU and memory, and the subset of resources (cpu, memory, disk) that triggered it. An already_at_max_size flag marks jobs that are on the largest available size and still asking for more, which is the signal to split the job or shard the test suite instead of buying a bigger machine.

Step 3: confirm in the Usage view before you move. Usage shows metrics and logs for an individual runner instance, with system logs and GitHub Actions logs in the same view. A job at 85 percent memory for 30 seconds during a bundle step is a different problem from a job that sits at 85 percent for 12 minutes. The chart tells them apart, and the correlated logs tell you which step owns the plateau.

Step 4: change one label and measure for a week. Run count in the Jobs report tells you how much traffic the job gets, which tells you how long you need to wait before the new p90 means anything. A job with 40 runs a week needs a full week. A job with 900 runs a month can be judged in two days.

Every runner WarpBuild operates carries the same agent. BYOC runs on AWS, GCP, and Azure, so the metrics for a runner inside your own cloud account arrive in the same reports as the metrics for a hosted one.

Configuration

Runner size is a label. Once you know which job is pinned and which is idle, the change is one line per job.

name: ci
on:
  pull_request:

jobs:
  lint:
    runs-on: warp-ubuntu-latest-x64-2x
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm run lint

  build:
    runs-on: warp-ubuntu-latest-x64-8x
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm run build

  test:
    needs: build
    runs-on: warp-ubuntu-latest-x64-4x
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm test -- --shard=${{ matrix.shard }}/4

Three sizes, three shapes of work: a single threaded lint on 2 vCPU, a core hungry build on 8 vCPU, a sharded test suite on 4 vCPU where the shard count does the parallelism. Publishing an image for a second architecture uses the ARM64 labels from the same catalog.

  build-arm:
    runs-on: warp-ubuntu-latest-arm64-8x
    steps:
      - uses: actions/checkout@v4
      - run: make release ARCH=arm64

The agent collecting the metrics needs port 33931 for telemetry, which is open by default on WarpBuild operated runners. On BYOC runners, confirm that egress on that port is allowed from the subnet your runners launch in, otherwise the Usage view stays empty while jobs run normally.

Pulling the numbers from the API

Create an API key with the ci scope, then request the aggregated per job metrics for a date window.

curl -sS -G 'https://api.warpbuild.com/api/v1/reports/jobs' \
  -H 'Authorization: Bearer wkey-xxxx' \
  -H 'Accept: application/json' \
  --data-urlencode 'start_date=2026-07-13T00:00:00Z' \
  --data-urlencode 'end_date=2026-08-13T00:00:00Z' \
  --data-urlencode 'chart_metric=cpu' \
  --data-urlencode 'chart_percentile=p90' \
  --data-urlencode 'sort_by=cpu_p90' \
  --data-urlencode 'sort_order=desc' \
  --data-urlencode 'per_page=50'

Each row in table.items carries the fields the dashboard renders.

FieldMeaning
repo, workflow_name, job_nameThe aggregation key for the row
run_countExecutions in the selected window
success_rateShare of runs that succeeded
duration_p75, duration_p90Execution time percentiles
queue_time_p75, queue_time_p90Percentiles of time spent waiting in the queue
cpu_p75, cpu_p90Percentiles of peak CPU utilization
memory_p75, memory_p90Percentiles of peak memory utilization

Flatten it into a table you can paste into a spreadsheet or a ticket:

curl -sS -G 'https://api.warpbuild.com/api/v1/reports/jobs' \
  -H 'Authorization: Bearer wkey-xxxx' \
  --data-urlencode 'start_date=2026-07-13T00:00:00Z' \
  --data-urlencode 'end_date=2026-08-13T00:00:00Z' \
  --data-urlencode 'runner_labels=warp-ubuntu-latest-x64-8x' \
  --data-urlencode 'sort_by=cpu_p90' \
  --data-urlencode 'sort_order=asc' \
| jq -r '.table.items[]
    | [.repo, .workflow_name, .job_name, .run_count,
       .duration_p90, .queue_time_p90, .cpu_p90, .memory_p90]
    | @tsv'

Add --data-urlencode 'format=csv' to get the same rows as CSV. All reports support date range selection, sorting, filtering, search, and CSV export, and the export always contains every row matching the current filters rather than the visible page.

The right sizing suggestions have their own endpoint, which returns one entry per job that should move plus a sample of that job's recent runs:

curl -sS -G 'https://api.warpbuild.com/api/v1/org_metrics/job_runner_recommendations' \
  -H 'Authorization: Bearer wkey-xxxx' \
  --data-urlencode 'per_page=200' \
| jq -r '.recommendations[]
    | [.repository, .workflow_name, .job_name,
       .recommendation.type,
       .recommendation.current_label,
       .recommendation.recommended_label,
       (.recommendation.resources | join(","))]
    | @tsv'

Run that on a schedule and you have a weekly diff of which jobs drifted. For an agent that reads these reports and proposes the label changes for you, see the guide to the WarpBuild MCP server. Full field definitions live in the Observability documentation and the Reports documentation.

Cost or Time Model

Runner size changes price in a straight line. Linux x64 rates from the WarpBuild pricing page, billed per minute:

LabelvCPURAMStorageUSD per minute
warp-ubuntu-latest-x64-2x28 GB150GB SSD$0.004
warp-ubuntu-latest-x64-4x416 GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832 GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664 GB150GB SSD$0.032
warp-ubuntu-latest-x64-32x32128 GB150GB SSD$0.064

Linux ARM64 carries the same shapes at a lower rate: warp-ubuntu-latest-arm64-4x at $0.006 per minute and warp-ubuntu-latest-arm64-8x at $0.012 per minute.

Assumptions used below. Rates are the published per minute rates above. Run counts and duration percentiles are the sort of numbers the Jobs report returns, stated per job per month. Billing is per minute of runner time; the CI billing table reports execution time and billed time per job separately, so use billed time when you reconcile against an invoice. Queue time is excluded because it is not billed.

Model A: an over provisioned lint job. The job runs 900 times a month, duration p90 is 6.0 minutes on warp-ubuntu-latest-x64-8x, cpu_p90 is 21 percent, memory_p90 is 17 percent. Current spend is 900 x 6.0 = 5,400 minutes at $0.016 = $86.40 a month. Moving it to warp-ubuntu-latest-x64-2x, and assuming the single threaded lint step stretches duration p90 to 7.5 minutes, gives 6,750 minutes at $0.004 = $27.00 a month. The break even point is the useful number here: at 900 runs, the 2x size stays cheaper than the 8x size until duration p90 passes 24.0 minutes, which no amount of stretching will reach. The intermediate stop, warp-ubuntu-latest-x64-4x at an assumed 6.5 minutes, costs 5,850 minutes at $0.008 = $46.80 a month.

Model B: a CPU pinned build job. The job runs 600 times a month, duration p90 is 18.0 minutes on warp-ubuntu-latest-x64-4x, cpu_p90 is 96 percent, memory_p90 is 62 percent. Current spend is 10,800 minutes at $0.008 = $86.40 a month. Moving it to warp-ubuntu-latest-x64-8x and assuming duration p90 falls to 11.0 minutes gives 6,600 minutes at $0.016 = $105.60 a month. That is $19.20 more per month and returns 7 minutes per run, which is 4,200 minutes of wall clock a month back to the people waiting on the pull request. The dollar break even sits at 9.0 minutes: below that the upgrade also lowers the bill. Going to warp-ubuntu-latest-x64-16x would need duration p90 at 4.5 minutes to hold the same $86.40, which is the test to run before assuming more cores keep paying.

For a baseline, GitHub publishes these per minute list prices for GitHub-hosted Linux runners, checked on 2026-08-13 in the GitHub billing reference:

GitHub-hosted runnerShapeUSD per minute
ubuntu-latest on private repositories2 vCPU, 8 GB$0.006
4-core Linux larger runner4 vCPU, 16 GB$0.012
8-core Linux larger runner8 vCPU, 32 GB$0.022

Run Model B's 6,600 minutes against the 8-core GitHub-hosted list price and the same work costs $145.20 a month, against $105.60 on warp-ubuntu-latest-x64-8x. That is the same 8 vCPU and 32 GB shape at $0.016 per minute against $0.022, which is 27 percent lower list price, checked on 2026-08-13.

For the wider picture of what the platform reports across a GitHub Actions estate, start at GitHub Actions observability with WarpBuild. If queue time rather than execution time is the number that moved, cut GitHub Actions queue times covers that path, and reduce GitHub Actions costs works the same data from the billing side.

FAQ

Does GitHub Actions report CPU and memory usage for a job?

No. GitHub Actions reports step timing, job timing, and exit status. CPU, memory, filesystem, and network utilization come from an agent running on the runner itself. WarpBuild collects them over OpenTelemetry on port 33931 and shows them next to the GitHub Actions logs for the same job.

Why do some jobs show a dash instead of CPU and memory in the Jobs report?

Two reasons. Observability collects metrics only for jobs longer than about one minute, and the CPU and memory columns require Observability to be enabled for the runner. Jobs with no telemetry render a dash in those columns and still report duration, queue time, run count, and success rate.

How do I decide whether a job should move to a larger or a smaller runner?

Read cpu_p90 and memory_p90 for that job in the Jobs report. Sustained values at or above 80 percent are the upgrade signal. Values in the low tens across every run are the downgrade signal. The Recommendations view applies those thresholds over the last 7 days and names the current label and the recommended label for each job.

Can I export runner metrics for analysis outside the dashboard?

Yes. Every report tab exports CSV with all rows matching the current filters and sort order, and the reports endpoints accept format=csv. The same data is reachable from the API with a wkey- API key carrying the ci scope.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.