gauge
A gauge records values you sample. Every observation in a window folds into five numbers, so a thousand readings a minute cost the same as one.
import { gauge, oneOf } from 'metrichouse/core'
export const onlineUsers = gauge('online_users', {
dims: { region: oneOf(['us-east', 'eu-west', 'ap-south']) },
resolution: '1m',
flush: '1m',
write: async (rows) => clickhouse.insert({ table: 'online_users', values: rows }),
})onlineUsers.set(1_284, { region: 'us-east' })| Import | import { gauge } from 'metrichouse/core' |
| Answers | What was this value when we looked |
| Storage | Folded into last, min, max, sum and count per window per series |
| Row | { id, bucket_ts, ...dims, last, min, max, sum, count } |
| Write with | set() |
| Read with | current(), totals(), snapshot() |
| Use it for | Users online, cache hit ratio, temperature, disk usage, memory in use |
A window nobody wrote to is absent, which on a chart is a gap. For a value that holds between the moments you look, such as queue depth, use level instead.
gauge()
gauge<D>(name: string, config: GaugeConfig<D>): Gauge<D>Declares a gauge. It is inert until a house binds it, and writing to an unbound gauge throws.
| Parameter | Type | Required | Meaning |
|---|---|---|---|
name | string | yes | The metric name |
config.dims | shape | no | Labels to break the value down by |
config.resolution | duration | yes | How wide one window is |
config.flush | duration | no | The fastest this may ship |
config.grace | duration | no | How long a window waits for observations on their way |
config.aggregate | array | no | Which of the five columns reach your sink |
config.collect | function | no | Read a value held elsewhere before each window closes |
config.collectLead | duration | no | How long before the end of a window to call it |
config.collectScope | 'fleet' or 'process' | no | Which processes call it |
config.write | function | yes | Where the rows go |
There is a third parameter, kind, which lets another metric type present itself as a gauge underneath. timer is the one that uses it, and it is listed under Extension points.
name
gauge('online_users', { ... })A non empty string, unique inside a house. It names the metric in every row id, in reports and in your table.
dims
dims?: Record<string, FieldType> // default: noneThe labels this value is broken down by. Each combination is folded separately, so us-east and eu-west keep their own minimum and maximum.
dims: { region: oneOf(['us-east', 'eu-west', 'ap-south']) }Leave it out for a gauge that is one series.
const heapUsedMb = gauge('heap_used_mb', { resolution: '10s', flush: '1m', write })
heapUsedMb.set(process.memoryUsage().heapUsed / 1024 / 1024)A dim cannot be named id, bucket_ts, or any aggregate the gauge ships, because each of those is already a column of the row.
dims covers the whole argument.
resolution
resolution: DurationInput // requiredHow wide one window is, and therefore how many observations fold together. A 1m window sampled every ten seconds folds six readings, which is enough for min and max to mean something.
Pick a resolution that holds several observations. One reading per window makes min, max and last the same number. Buckets and time covers the choice, and Durations the format.
flush
flush?: DurationInput // default: the house defaultThe fastest this gauge may ship. resolution has to divide it evenly, and a gauge with no cadence anywhere throws when the house registers it. Identical to the counter's.
grace
grace?: DurationInput // default: the house default, then '2s'How long a window waits after it ends before a flush may claim it, so observations made inside it have time to reach storage. Identical to the counter's.
aggregate
aggregate?: readonly ('last' | 'min' | 'max' | 'sum' | 'count')[] // default: all fiveWhich columns reach your sink. Ask for fewer when you know what you will query.
const cacheHitRatio = gauge('cache_hit_ratio', {
aggregate: ['sum', 'count'], // enough to compute an average
resolution: '1m',
flush: '1m',
write,
})All five are folded whatever you ask for. The saving is columns written rather than work done, so widening this later needs no migration of anything already in flight.
import { GAUGE_AGGREGATES } from 'metrichouse/core'
// ['last', 'min', 'max', 'sum', 'count']An empty array throws, and so does a name that is not one of the five or a name given twice.
collect
collect?: (metric: Gauge<D>) => void | Promise<void> // default: noneA function MetricHouse calls a little before each window closes, for a value that lives somewhere else and only needs reading. It receives the gauge, calls set() on it, and the observations land in the window that is about to close.
const heapUsedMb = gauge('heap_used_mb', {
resolution: '1m',
flush: '1m',
collect: (self) => self.set(process.memoryUsage().heapUsed / 1024 / 1024),
collectScope: 'process', // every process has a heap of its own
write,
})The production shape reads a number your application already keeps. Here the sessions are a sorted set per region in your own Redis, scored by the last time each user was seen.
// metrics/schema.ts
import { Redis } from 'ioredis'
import { gauge, oneOf } from 'metrichouse/core'
import { toClickHouse } from './sinks.js'
const redis = new Redis(process.env.REDIS_URL!)
const REGIONS = ['us-east', 'eu-west', 'ap-south'] as const
export const onlineUsers = gauge('online_users', {
dims: { region: oneOf(REGIONS) },
resolution: '1m',
flush: '1m',
// One reading per region per minute, taken a second before the minute ends.
// The default scope, 'fleet', means one server takes it for all of them,
// because every server would read the same sorted sets.
collect: async (self) => {
const since = Date.now() - 5 * 60_000
const counts = await Promise.all(
REGIONS.map((region) => redis.zcount(`sessions:${region}`, since, '+inf')),
)
REGIONS.forEach((region, i) => self.set(counts[i] ?? 0, { region }))
},
write: toClickHouse('online_users'),
})With house.start(), a timer calls it collectLead before each window ends. Without the scheduler, every flush() calls it first, at most once per window, and house.stop() calls it once more before its final flush. Collecting before a window closes covers each of those, and what happens when it throws, runs long or runs on several servers.
An observation lands in the window open at the moment set() is called. A collect that awaits a read has to finish inside the lead, or its readings fall into the next window. A collect that is not a function throws at declaration.
collectLead
collectLead?: DurationInput // default: '1s', or a tenth of a shorter resolutionHow long before the end of each window the scheduler calls collect. Make it longer than the read inside collect takes.
collect: readFromWarehouse,
collectLead: '10s', // a slow query, so start it ten seconds before the minute endsThe default is one second, or a tenth of the resolution when that is shorter, so a 5s window is collected 500 milliseconds before it ends. A lead of zero, or one as long as the resolution or longer, throws at declaration, and so does a collectLead given without collect. Durations covers the format.
collectScope
collectScope?: 'fleet' | 'process' // default: 'fleet'Which processes call collect when several share one driver.
| Scope | Calls per window | What the window holds |
|---|---|---|
'fleet' | One, from whichever process asks first | One observation, so last, min and max are the reading |
'process' | One per process | One observation per process, folded together like any other |
Keep 'fleet' for a value every process would read the same, such as the sorted sets above. Use 'process' for a value each process has its own of, such as its heap, and give the gauge an instance dim so each process keeps a series of its own. On a driver that is not shared, such as memory(), the two are the same. A collectScope given without collect throws, and so does one that is neither of the two. Collecting across a fleet explains how one process is picked.
write
write: (rows: GaugeRow<D>[], context: WriteContext) => Promise<void> | voidWhere the rows go. Required.
| Parameter | Type | Meaning |
|---|---|---|
rows | GaugeRow<D>[] | Dims typed as declared, each aggregate typed number | undefined |
context | WriteContext | Which metric, which window, how many, and which attempt |
The aggregates are optional in the type because aggregate decides at run time which of them a row carries. context.total is every observed value added up whichever columns ship, so a gauge that ships only min and max still reports the sum of what it saw. Writing a sink covers the contract in full.
gauge.set()
set(value: number, dims: Dims): void
set(value: number): void // no dims declaredRecords one observation into the window that is open now.
| Parameter | Type | Required | Meaning |
|---|---|---|---|
value | number | yes | The reading |
dims | the declared shape | once any dim is declared | Which series this reading belongs to |
onlineUsers.set(1_284, { region: 'us-east' })
onlineUsers.set(1_301, { region: 'us-east' })
onlineUsers.set(1_297, { region: 'us-east' })
// The window now holds:
// { last: 1297, min: 1284, max: 1301, sum: 3882, count: 3 }A second call in the same window adds an observation rather than replacing the first. For a value that replaces, see level.set().
Returns nothing, and returns before storage has acknowledged anything. drain() is what confirms a write landed.
Throws immediately on an unbound gauge, a value that is not finite, or dims that are missing, unknown or ill typed. A failure on the way to storage goes to the house's onError handler.
gauge.current()
current(dims: Dims): Promise<GaugeCell | undefined>
current(): Promise<GaugeCell | undefined> // no dims declaredThe open window's fold for one series. The open window is the one a write made now lands in, as for counter.current().
await onlineUsers.current({ region: 'us-east' })
// { last: 1297, min: 1284, max: 1301, sum: 3882, count: 3 }Returns the five numbers, or undefined when nothing has been observed in this window. The object is a copy, on every driver, so editing it changes nothing that ships. A zeroed object would claim a min of 0 for a gauge nobody wrote to, and a chart should show a gap there.
Unlike a counter, the dims argument is required as soon as the gauge declares any. Use totals() for every series at once.
gauge.totals()
totals(): Promise<GaugeTotals | undefined>Every series in the open window, merged.
await onlineUsers.totals()
// { min: 612, max: 1301, sum: 7284, count: 6 }Returns min, max, sum and count, or undefined when nothing has been observed anywhere.
There is no last. Several series have no single latest observation, and picking one would be the same mistake as storing an average.
gauge.snapshot()
snapshot<O extends SnapshotOptions>(options?: O): Promise<GaugeLiveRow<D, O>[]>Every closed window of folds that has not been flushed and acknowledged, as rows. complete: false adds the open window, and a window a flush has claimed but not yet acknowledged is out of view until that flush settles.
await onlineUsers.snapshot()
await onlineUsers.snapshot({ dims: { region: 'us-east' } })
await onlineUsers.snapshot({ rollup: 'sum', groupBy: ['region'] })A rollup merges folds the way the five aggregates merge: sum and count add, min and max take the extreme, and last takes the latest window. Every option is in Snapshot options.
A merged row has no last when several series share the newest window in the group, because a fold does not record which series was observed most recently. rollup: 'sum' on one series keeps it, and a groupBy that merges series inside their newest window drops it. A sum across series that passes the largest double throws, as does totals(), instead of returning Infinity.
gauge.flush()
flush(options?: FlushOptions): Promise<MetricFlushReport>Ships every closed window to this gauge's own write function. Flush options covers the argument and the report.
gauge.drain()
drain(): Promise<void>Resolves once every set() issued before the call has reached the driver. On a gauge declared with collect, it first waits for a collect still running, so the readings it is about to write are waited for too. Under immediate delivery it also waits for the send to write that follows each one.
gauge.rowShape()
rowShape(): RowShapeThe columns your sink will receive, in order, with only the aggregates you asked for.
onlineUsers.rowShape().columns.map((c) => c.name)
// ['id', 'bucket_ts', 'region', 'last', 'min', 'max', 'sum', 'count']
cacheHitRatio.rowShape().columns.map((c) => c.name)
// ['id', 'bucket_ts', 'sum', 'count']Properties
| Property | Type | Value |
|---|---|---|
name | string | The name it was declared with |
kind | 'gauge' | |
storage | 'bucketed' | It folds writes into windows |
dims | Shape | The declared dims |
resolutionMs | number | resolution, parsed |
flushMs | number | flush, parsed, including one taken from the house. Throws on an unbound metric that declared no flush |
graceMs | number | grace, parsed, including one taken from the house. 2000 when neither sets it |
aggregate | readonly GaugeAggregate[] | The columns this gauge writes |
isBound | boolean | true once a house has registered it |
write | WriteFn | The function it was declared with |
The row
{
id: '...',
bucket_ts: Date,
region: 'us-east',
last: 1297,
min: 1284,
max: 1301,
sum: 3882,
count: 3,
}| Column | Meaning |
|---|---|
id | Derived from the name, the window and the dim values |
bucket_ts | The start of the window |
| one per dim | The label values for this series |
last | The most recent value observed in this window |
min | The smallest |
max | The largest |
sum | Every value added together |
count | How many observations there were |
Inside write, each row is a GaugeRow<D>. See Rows are typed.
Why there is no average
These five merge across windows. An average does not.
One minute averaging 10 and the next averaging 20 do not make 15 across both, unless both held the same number of observations. Storing an average would produce wrong numbers the moment anyone grouped by hour.
sum and count merge, and sum / count gives the exact average whenever you ask for it.
SELECT
toStartOfHour(bucket_ts) AS hour,
region,
sum(sum) / sum(count) AS avg_online,
min(min) AS lowest,
max(max) AS highest
FROM online_users
GROUP BY hour, region;That is the whole reason the five are what they are. It is also why a rollup merges the way it does in snapshot().
Table schema
CREATE TABLE online_users (
id String,
bucket_ts DateTime64(3),
region LowCardinality(String),
last Float64,
min Float64,
max Float64,
sum Float64,
count Int64
)
ENGINE = ReplacingMergeTree
ORDER BY (bucket_ts, region);CREATE TABLE online_users (
id TEXT PRIMARY KEY,
bucket_ts TIMESTAMPTZ NOT NULL,
region TEXT NOT NULL,
last DOUBLE PRECISION NOT NULL,
min DOUBLE PRECISION NOT NULL,
max DOUBLE PRECISION NOT NULL,
sum DOUBLE PRECISION NOT NULL,
count BIGINT NOT NULL
);A gauge against a level
Both hold a number per series. They differ in what a window with no write means.
gauge | level | |
|---|---|---|
| A second write in one window | another observation | the value changing |
| A window nobody wrote to | absent, a gap on the chart | a row carrying the last value |
| Stores | last, min, max, sum, count | one value |
current() | the fold, or undefined | the held value, or undefined |
| Rows per day | one per window something happened in | one per window, per series, always |
| Reach for it when | you are sampling and want the spread | the gap between writes is what you need filled |
const queueDepth = level('queue_depth', { resolution: '1m', flush: '1m', write })
queueDepth.set(42) // every minute reports 42 until this changesWriting to both is reasonable when you want the held line and the spread inside each window.
Patterns
Sampling system health on a timer
// metrics/schema.ts
import { gauge, oneOf, str } from 'metrichouse/core'
import { toClickHouse } from './sinks.js'
export const systemHealth = gauge('system_health', {
dims: {
metric: oneOf(['heap_used_mb', 'rss_mb', 'event_loop_lag_ms', 'db_pool_in_use']),
instance: str(),
},
// One row per minute per measurement, which is plenty for capacity work.
resolution: '1m',
flush: '1m',
write: toClickHouse('system_health'),
})// metrics/sampler.ts
import { monitorEventLoopDelay } from 'node:perf_hooks'
import { systemHealth } from './schema.js'
import { pool } from '../db.js'
const instance = process.env.HOSTNAME ?? 'local'
const loopDelay = monitorEventLoopDelay({ resolution: 10 })
loopDelay.enable()
export function startSampling() {
// Every ten seconds gives six observations per minute, so min and max are
// meaningful and no window is ever empty.
const handle = setInterval(() => {
const memory = process.memoryUsage()
systemHealth.set(memory.heapUsed / 1024 / 1024, { metric: 'heap_used_mb', instance })
systemHealth.set(memory.rss / 1024 / 1024, { metric: 'rss_mb', instance })
systemHealth.set(loopDelay.mean / 1e6, { metric: 'event_loop_lag_ms', instance })
systemHealth.set(pool.numUsed(), { metric: 'db_pool_in_use', instance })
loopDelay.reset()
}, 10_000)
// Sampling should never be the reason the process stays alive.
handle.unref()
return () => clearInterval(handle)
}-- The worst event loop lag per instance, per hour.
SELECT
toStartOfHour(bucket_ts) AS hour,
instance,
max(max) AS worst_lag_ms,
sum(sum) / sum(count) AS avg_lag_ms
FROM system_health
WHERE metric = 'event_loop_lag_ms'
AND bucket_ts >= now() - INTERVAL 1 DAY
GROUP BY hour, instance
ORDER BY worst_lag_ms DESC;Shedding load on a live reading
The fold is readable before it ships, so a gauge can drive a decision.
export async function shouldShedLoad() {
const lag = await systemHealth.current({ metric: 'event_loop_lag_ms', instance })
// No observation yet in this window, so no reason to act.
if (!lag) return false
return lag.max > 250
}Playground
The fold
Drag the observations. Everything below them is what MetricHouse keeps, and the average line is the one number it refuses to store.
Try it: what one bucket folds to
Drag each observation. These are 4 observations inside one bucket, and the five numbers below are everything MetricHouse keeps about them.
{ id: '4f2c18a6…', bucket_ts: Date, region: 'us-east', last: 1,266, min: 640, max: 1,301, sum: 4,187, count: 4 } Average is sum / count = 1046.8, computed in SQL. It is not stored because an average cannot be merged across buckets.
Turn sum or count off and the average becomes unrecoverable, which is the argument for keeping both.
The windows
A gauge buckets exactly like a counter, so the same two settings decide the same things. Each row carries five columns rather than one.
Try it: resolution inside flush
The declaration these settings produce
gauge('online_users', {
resolution: '1m',
flush: '1m',
write: async (rows) => db.insert(rows),
}) Your sink receives id, bucket_ts, …dims, min, max, sum, count, one row per combination per bucket.