CI Observability: Runner Utilization and the Wrong Question

2026/03/04

BUILDcidevexinfra

We run 56,000 CI jobs a day across a dozen runner pools. Somebody asked the obvious question: how busy are these runners? Reasonable question. Turns out the answer — and the real question — are both weirder than you’d expect.

The shape of the problem

Our runners are ephemeral. One job, one instance, gone. There’s no machine you can SSH into and run htop. You can’t check CPU usage because the CPU didn’t exist five minutes ago and won’t exist five minutes from now.

What you can do is look at the CI events flowing into Datadog. Every job has a start time, a duration, a runner label, and a queue time. So the question becomes: given a stream of (start, duration) tuples, how many jobs are running concurrently at any given moment?

This is the “overlapping intervals” problem. Every CS grad has solved it on a whiteboard. Datadog has… not solved it.

56K jobs, 12 pools, very unequal

Before we get into the visualization rabbit hole, here’s what a day of CI actually looks like:

Label Jobs/day Avg Duration Total Compute
yolo-ubuntu-latest 23,062 ~10s ~64h
ubuntu-latest 9,377 ~15s ~39h
yolo-ubuntu-2404-x64-4c.16g.150g 7,214 ~60s ~120h
big 1,932 ~356s ~191h
gpu-condor2-rss-dind 1,057 ~300s ~88h

The yolo-ubuntu-latest pool handles 23K jobs but each one takes 10 seconds — it’s lint checks and fast validations. The big pool runs fewer than 2K jobs but they average 6 minutes each, consuming 191 hours of compute in a 24-hour window. That’s 8 days of wall-clock compute squeezed into one calendar day.

So “how busy” depends entirely on whether you mean “how many jobs” or “how much compute.”

Datadog can’t count overlapping intervals

Here’s the thing about Datadog CI analytics — it stores events, not time series. Each job is a point with a start time and a duration. To know how many jobs overlap at time T, you’d need to check every job where start <= T < start + duration.

DD doesn’t do that. There’s no native “count overlapping intervals at each point” function. Not in dashboards, not in notebooks, not in the query language.

The standard workaround is the cumsum trick: bucket your timeline (say, 1-minute windows), count how many jobs started in each bucket, count how many ended in each bucket, then compute the cumulative sum of starts minus cumulative sum of ends. At any bucket, that running total approximates concurrent jobs.

concurrency[t] ≈ cumsum(starts[0..t]) - cumsum(ends[0..t])

It works. Mostly. The cumulative sums accumulate rounding errors across bucket boundaries — a job ending at 14:59:59.9 gets bucketed differently than one ending at 15:00:00.1, and that drift adds up over 24 hours. For a rough “are we at 5 or 50 concurrent jobs” it’s fine. For capacity planning with precision, it’s not great.

Someone already built the dashboard

Plot twist: halfway through this investigation, I found a Runner Infra dashboard that already existed. It had the cumsum approach implemented, plus some queue time panels.

This is the DevEx experience in a nutshell. You spend two hours reinventing something, then discover someone on another team built it six months ago. The dashboard wasn’t wrong — it just wasn’t answering the question I actually needed answered.

The script approach

I also wrote a Python script that pulls raw events from the DD API and computes exact concurrency. No bucketing, no cumsum drift — just sort all start and end timestamps, sweep through them, track the running count.

For the big runner pool on one day: peak 20 concurrent jobs at 16:57 UTC. The API returns 1,000 events per page with cursor pagination, so a full day of big jobs takes about 2 pages. The yolo-ubuntu-latest pool? 57 pages. API rate limits start mattering real fast.

The script gives you exact numbers but doesn’t scale to a live dashboard. It’s an audit tool, not a monitoring solution.

Queue time is the real metric

Here’s the thing I should have realized at the start: concurrency doesn’t tell you what you actually want to know.

Knowing that we peak at 20 concurrent big jobs is… interesting? But what do you do with that? Is 20 too many? Too few? Right-sized? You can’t tell from concurrency alone. You’d need to know the pool’s capacity limit, compare it to the peak, and even then — if the peak only lasts 30 seconds, does it matter?

Queue time answers the real question: are developers waiting?

P95 queue time by runner label, trended over time, is the metric that actually drives scaling decisions. And DD already stores queue time on every event — @ci.queue_time in nanoseconds. No cumsum tricks, no bucket drift, no 57-page API pagination. Just a straightforward percentile aggregation.

The concurrency visualization is cool. The queue time chart is useful.

What I’d actually build

If I were setting up runner monitoring from scratch:

The concurrency problem is a fun puzzle. But the queue time was sitting right there in the data the whole time, answering a better question.