monitor.rollup
Aggregate a metric series into fixed time buckets (count, min, max, avg, sum, last), empty buckets included.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 15 tests, run in TypeScript, Python and Rust.
What it does
Turns a raw metric series into one row per fixed time bucket (a minute, an hour, a day) with the count, min, max, average, sum and last value: the table behind a latency or throughput chart, or a coarser copy kept for longer retention. `charts.downsample` is the other tool: it picks points that keep a line's shape (LTTB); this one aggregates.
## Buckets
For example
rollup(samples ×5, 0, 180, 60)→ ×3 three one-minute bucketsrollup(samples ×2, 0, 180, 60)→ ×3 an empty bucket in the middle is kept, so a chart shows the gaprollup(samples ×3, 0, 100, 60)→ ×2 the last bucket is clipped to to
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
export function rollup(samples: readonly MetricSample[], from: number, to: number, bucketSeconds: number): readonly Bucket[]
| samples | MetricSample[] | in strictly ascending time order |
| from | int | start of the first bucket, Unix seconds; buckets are aligned to it |
| to | int | end of the last bucket (excluded); the last bucket is clipped to it |
| bucketSeconds | int | bucket width, at least 1; at most 10000 buckets |
| returns | Bucket[] | every bucket from `from` to `to`, oldest first, empty ones included |
The type it declares, generated into your project
/** The samples with start <= at < end, aggregated. */
export interface Bucket {
/** Unix seconds, included */
readonly start: number;
/** Unix seconds, excluded */
readonly end: number;
readonly count: number;
/** null when the bucket is empty */
readonly min: number | null;
/** null when the bucket is empty */
readonly max: number | null;
/** sum / count, half-up (halves away from zero); null when empty */
readonly avg: number | null;
/** 0 when empty */
readonly sum: number;
/** value of the latest sample; null when empty */
readonly last: number | null;
}
Your code names it in one line, in the file that uses it
import { rollup } from "#fune/monitor.rollup@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { roundDiv } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { seriesWindow, type MetricSample } from "./monitor_series_window.ts"; ← from monitor.series-window ^1.0.0 · built alongside by fune
import { type Bucket } from "./monitor_rollup_types.ts";
const MAX_BUCKETS = 10000;
/**
* One row per bucket from `from` to `to`, empty buckets included so a chart
* shows gaps as gaps. Buckets are aligned to `from`; the last is clipped to `to`.
*/
export function rollup(samples: readonly MetricSample[], from: number, to: number, bucketSeconds: number): readonly Bucket[] {
if (!Number.isInteger(bucketSeconds)) throw new RangeError("bucketSeconds must be a whole number of seconds");
if (bucketSeconds < 1) throw new RangeError(`bucketSeconds must be at least 1, received ${bucketSeconds}`);
const inside = seriesWindow(samples, from, to);
const n = Math.ceil((to - from) / bucketSeconds);
if (n > MAX_BUCKETS) throw new RangeError(`too many buckets: ${n}, at most ${MAX_BUCKETS}`);
const out: Bucket[] = [];
let j = 0;
for (let i = 0; i < n; i++) {
const start = from + i * bucketSeconds;
const end = Math.min(start + bucketSeconds, to);
let count = 0;
let sum = 0;
let min: number | null = null;
let max: number | null = null;
let last: number | null = null;
while (j < inside.length && inside[j].at < end) {
const v = inside[j].value;
count += 1;
sum += v;
min = min === null || v < min ? v : min;
max = max === null || v > max ? v : max;
last = v;
j++;
}
const avg = count === 0 ? null : roundDiv(sum, count, "half-up");
out.push({ start, end, count, min, max, avg, sum, last });
}
return out;
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add monitor.rollup
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./monitor.rollup-1.0.0-typescript.fune, or fetch it from a terminal with fune pull monitor.rollup@1.0.0:typescript.
The whole function, every language, is one file too: monitor.rollup-1.0.0.fune, 15,684 bytes, sha256 24f1815aeec79e93ba09881a92cd6a2eea649de96822ffbc1de2eda986c78e0c. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before monitor.rollup
after — your function gets the result and the arguments, and returns the final result.
// fune: after monitor.rollup
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace math.round-div in monitor.rollup
// fune: replace monitor.series-window in monitor.rollup
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show monitor.rollup --steps.
// fune: step monitor.rollup after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| three one-minute buckets | samples ×5, 0, 180, 60 | → | ×3 |
| an empty bucket in the middle is kept, so a chart shows the gap | samples ×2, 0, 180, 60 | → | ×3 |
| the last bucket is clipped to to | samples ×3, 0, 100, 60 | → | ×2 |
| a sample exactly at to is outside the window | samples ×2, 0, 120, 60 | → | ×2 |
| averages round half up, halves away from zero: 1.5 is 2 and -1.5 is -2 | samples ×4, 0, 20, 10 | → | ×2 |
| buckets are aligned to from, and samples outside the window are ignored | samples ×5, 45, 165, 60 | → | ×2 |
| last is the latest sample, not the largest | samples ×2, 0, 10, 10 | → | ×1 |
| a bucket wider than the window is one clipped bucket | samples ×1, 0, 30, 60 | → | ×1 |
| an empty window has no buckets | samples ×1, 100, 100, 60 | → | |
| no samples gives empty buckets, not no buckets | , 0, 120, 60 | → | ×2 |
Show the other 5 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a zero-width bucket is an error | , 0, 60, 0 | → | error: bucketSeconds must be at least 1, received 0 |
| a fractional bucket width is an error | , 0, 60, 1.5 | → | error: bucketSeconds must be a whole number of seconds |
| more than 10000 buckets is an error | , 0, 10,001, 1 | → | error: too many buckets: 10001, at most 10000 |
| from after to is an error | , 200, 100, 60 | → | error: from must not be after to: 200 > 100 |
| misordered samples are an error | samples ×2, 0, 60, 60 | → | error: samples must be in strictly ascending time order: 5 follows 10 |
More from the author
Buckets start at `from` and step by `bucketSeconds`, so they are aligned to `from`, not to the epoch: pass a `from` on a minute or hour boundary to get round buckets. Each is half-open, `start <= at < end`, like `monitor.series-window`, so no sample lands in two buckets. The last bucket is clipped at `to` when the window is not a whole number of buckets.
Empty buckets are returned too (count 0, sum 0, the rest null). A chart then shows a gap where data is missing instead of drawing a line straight across it, and every result for the same window has the same number of rows.
`from == to` gives no buckets. More than 10000 buckets is refused: that is almost always a bucket size in the wrong unit (seconds for minutes).
## Average
`avg` is `sum / count` rounded half up with `math.round-div`, whose half-up rounds halves away from zero: -1 and -2 average to -2, as 1 and 2 average to 2 (`Math.round(-1.5)` in JavaScript gives -1). Keep the `sum` and `count` if you need to combine buckets later: averaging averages is wrong when counts differ.
## Errors
- `bucketSeconds must be at least 1, received 0` - `bucketSeconds must be a whole number of seconds` - `too many buckets: 10001, at most 10000` - from `monitor.series-window`: `from must not be after to: 200 > 100`, `samples must be in strictly ascending time order: 5 follows 10`, `from and to must be whole seconds`
Files
| Path | Bytes |
|---|---|
| README.md | 1,762 |
| impl/python.py | 1,708 |
| impl/rust.rs | 2,742 |
| impl/typescript.ts | 1,640 |
| vectors.json | 4,000 |