Skip to content

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.

ts
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 }),
})
ts
onlineUsers.set(1_284, { region: 'us-east' })
Importimport { gauge } from 'metrichouse/core'
AnswersWhat was this value when we looked
StorageFolded into last, min, max, sum and count per window per series
Row{ id, bucket_ts, ...dims, last, min, max, sum, count }
Write withset()
Read withcurrent(), totals(), snapshot()
Use it forUsers 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() ​

ts
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.

ParameterTypeRequiredMeaning
namestringyesThe metric name
config.dimsshapenoLabels to break the value down by
config.resolutiondurationyesHow wide one window is
config.flushdurationnoThe fastest this may ship
config.gracedurationnoHow long a window waits for observations on their way
config.aggregatearraynoWhich of the five columns reach your sink
config.collectfunctionnoRead a value held elsewhere before each window closes
config.collectLeaddurationnoHow long before the end of a window to call it
config.collectScope'fleet' or 'process'noWhich processes call it
config.writefunctionyesWhere 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 ​

ts
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 ​

ts
dims?: Record<string, FieldType>      // default: none

The labels this value is broken down by. Each combination is folded separately, so us-east and eu-west keep their own minimum and maximum.

ts
dims: { region: oneOf(['us-east', 'eu-west', 'ap-south']) }

Leave it out for a gauge that is one series.

ts
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 ​

ts
resolution: DurationInput      // required

How 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 ​

ts
flush?: DurationInput      // default: the house default

The 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 ​

ts
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 ​

ts
aggregate?: readonly ('last' | 'min' | 'max' | 'sum' | 'count')[]   // default: all five

Which columns reach your sink. Ask for fewer when you know what you will query.

ts
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.

ts
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 ​

ts
collect?: (metric: Gauge<D>) => void | Promise<void>      // default: none

A 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.

ts
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.

ts
// 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 ​

ts
collectLead?: DurationInput      // default: '1s', or a tenth of a shorter resolution

How long before the end of each window the scheduler calls collect. Make it longer than the read inside collect takes.

ts
collect: readFromWarehouse,
collectLead: '10s',      // a slow query, so start it ten seconds before the minute ends

The 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 ​

ts
collectScope?: 'fleet' | 'process'      // default: 'fleet'

Which processes call collect when several share one driver.

ScopeCalls per windowWhat the window holds
'fleet'One, from whichever process asks firstOne observation, so last, min and max are the reading
'process'One per processOne 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 ​

ts
write: (rows: GaugeRow<D>[], context: WriteContext) => Promise<void> | void

Where the rows go. Required.

ParameterTypeMeaning
rowsGaugeRow<D>[]Dims typed as declared, each aggregate typed number | undefined
contextWriteContextWhich 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() ​

ts
set(value: number, dims: Dims): void
set(value: number): void      // no dims declared

Records one observation into the window that is open now.

ParameterTypeRequiredMeaning
valuenumberyesThe reading
dimsthe declared shapeonce any dim is declaredWhich series this reading belongs to
ts
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() ​

ts
current(dims: Dims): Promise<GaugeCell | undefined>
current(): Promise<GaugeCell | undefined>      // no dims declared

The open window's fold for one series. The open window is the one a write made now lands in, as for counter.current().

ts
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() ​

ts
totals(): Promise<GaugeTotals | undefined>

Every series in the open window, merged.

ts
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() ​

ts
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.

ts
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() ​

ts
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() ​

ts
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() ​

ts
rowShape(): RowShape

The columns your sink will receive, in order, with only the aggregates you asked for.

ts
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 ​

PropertyTypeValue
namestringThe name it was declared with
kind'gauge'
storage'bucketed'It folds writes into windows
dimsShapeThe declared dims
resolutionMsnumberresolution, parsed
flushMsnumberflush, parsed, including one taken from the house. Throws on an unbound metric that declared no flush
graceMsnumbergrace, parsed, including one taken from the house. 2000 when neither sets it
aggregatereadonly GaugeAggregate[]The columns this gauge writes
isBoundbooleantrue once a house has registered it
writeWriteFnThe function it was declared with

The row ​

ts
{
  id: '...',
  bucket_ts: Date,
  region: 'us-east',
  last: 1297,
  min: 1284,
  max: 1301,
  sum: 3882,
  count: 3,
}
Four observed values fold into last, min, max, sum and count.
Four observations, five stored numbers, no average.
ColumnMeaning
idDerived from the name, the window and the dim values
bucket_tsThe start of the window
one per dimThe label values for this series
lastThe most recent value observed in this window
minThe smallest
maxThe largest
sumEvery value added together
countHow 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.

sql
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 ​

sql
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);
sql
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.

gaugelevel
A second write in one windowanother observationthe value changing
A window nobody wrote toabsent, a gap on the charta row carrying the last value
Storeslast, min, max, sum, countone value
current()the fold, or undefinedthe held value, or undefined
Rows per dayone per window something happened inone per window, per series, always
Reach for it whenyou are sampling and want the spreadthe gap between writes is what you need filled
ts
const queueDepth = level('queue_depth', { resolution: '1m', flush: '1m', write })

queueDepth.set(42)   // every minute reports 42 until this changes

Writing to both is reasonable when you want the held line and the spread inside each window.

Patterns ​

Sampling system health on a timer ​

ts
// 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'),
})
ts
// 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)
}
sql
-- 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.

ts
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.

max 1,301min 640 average 1046.8 · derived, never stored 640#11,301#2980#31,266last
aggregate: all five are always folded. This only picks which become columns.
the row your write function receives{ 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

flush window 1 · 1m1 × 1m flush window 2 · 1m1 × 1m one bucket = 1m time
Each outer box is one flush. Every bucket inside it ships together, in one call to your write function.
bucket 1m a write stamped inside the bucket is claimed only after the grace has passed
1buckets per flushrows arrive together
8rows per flushbuckets × combinations
12krows per daywhat your table stores
1.4kwrites per daycalls to your database
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.

Released under the MIT License.