Skip to content

Metric types ​

There are six. Pick by the question you want to answer later.

TypeAnswersStorageWrite withRead with
counterHow many times did this happenFoldedadd()current(), snapshot()
gaugeWhat was this value when we lookedFoldedset()current(), totals(), snapshot()
levelWhat is this value now, and what was it in betweenFoldedset(), inc(), dec()current(), totals(), snapshot()
timerHow long did this takeFoldedtime(), start(), observe()current(), totals(), snapshot()
eventWhat exactly happened, with all the detailKept wholerecord(), recordMany()pending(), peek(), snapshot()
logWhat did the application say, and how serious was itKept wholeinfo() and one method per levelpending(), peek(), snapshot()

Choosing one ​

Are you tallying occurrences? Use a counter. Requests served, errors, signups, emails sent, bytes transferred, money taken.

Are you sampling a value, and do you want the spread of your readings? Use a gauge. Users online, cache hit ratio, temperature, disk usage.

Does the value hold between the times you write to it? Use a level. Queue depth, requests in flight, connections checked out of a pool. A gauge leaves a hole in every window nobody wrote to, and a level carries the last value in.

Are you measuring how long something took? Use a timer. It is a gauge of durations with the clock handling built in.

Do you need per item detail you cannot label? Use an event. A user id, a request id, a free text note, a JSON payload. Anything unique per occurrence.

Are you writing application messages with a severity? Use a log. It is an event with a level, a filter and a bound child logger.

Shared arguments ​

Four arguments appear on more than one type, and each has a page of its own.

ArgumentOnPage
dimscounter, gauge, level, timerdims
fieldsevent, logfields
resolution, flush, grace, holdFor, batch.maxAgeevery type that takes a timeDurations
snapshot(options)every typeSnapshot options
flush(options)every typeFlush options
writeevery typeWriting a sink

The declaration settings each type takes, side by side:

Settingcountergaugeleveltimereventlog
dimsyesyesyesyes
fieldsrequiredyes
resolutionrequiredrequiredrequiredrequired
flushyesyesyesyesyesyes
graceyesyesyesyes
valueint()float()
aggregatefivefour
holdForyes
recordyes
stage, batch, claimLimityesyes
timestamp, sample, deriveyes
levels, minLevelyes
writerequiredrequiredrequiredrequiredrequiredrequired

Configuration has the same information as one table per type, with defaults.

Shared members ​

Every metric, whatever it measures, carries these.

ts
metric.name
metric.kind          // 'counter' | 'gauge' | 'level' | 'event' | 'log' | 'timer'
metric.storage       // 'bucketed' | 'staged'
metric.flushMs
metric.isBound
metric.rowShape()    // the exact columns your sink will receive
metric.snapshot()    // unshipped rows
metric.flush()       // ship now, if the cadence allows
metric.drain()       // wait for queued writes to reach the driver

Every metric in the API index lists them with types.

Folded against kept whole ​

Folded storage adds writes together into one number. Kept whole storage queues each record separately.
The split that explains most of the behaviour you will meet.

Folded types combine writes into one value per time window. Memory use depends on how many label combinations you have rather than on how much traffic you get, so a thousand increments in one second become one row.

A level folds too, and differs in one way worth knowing before you pick it: it writes a row for every window whether or not anything happened, because a held value is only useful if it is there in the windows nobody touched.

Kept whole types keep every record. Memory use grows with traffic until the records ship, which is why they have batching settings and a backlog you can query.

ts
requests.storage   // 'bucketed'
signups.storage    // 'staged'

Using two together ​

Counters and events are complementary, and the common pattern is to write both.

ts
const checkouts = counter('checkouts', {
  dims: { plan: oneOf(['free', 'pro', 'team']), outcome: oneOf(['paid', 'failed']) },
  resolution: '1m',
  flush: '1m',
  write: toClickHouse('checkouts'),
})

const checkoutAttempted = event('checkout_attempted', {
  fields: {
    userId: str(),
    plan: oneOf(['free', 'pro', 'team']),
    outcome: oneOf(['paid', 'failed']),
    amountCents: int(),
    failureReason: str().optional(),
  },
  flush: '1m',

  // Every event also increments the counter. No second write path to forget.
  derive: {
    checkouts: (fields) => ({ dims: { plan: fields.plan, outcome: fields.outcome } }),
  },

  write: toClickHouse('checkout_attempted'),
})

The counter stays small and is fast to query for charts. The event holds the detail you need when a number looks wrong. derive keeps them consistent, and counts every event, including the ones sampling drops, so the counter stays exact even when the event table only keeps a slice.

The same pairing for durations is a timer with record.

Things that are deliberately missing ​

No histogram. Percentiles are a query over rows you already have. Record the values to an event and write quantile(0.95)(duration_ms).

No average on a gauge. It stores sum and count. An average cannot be merged across windows, so storing it would produce wrong numbers when two windows are combined. sum / count is exact and one division away.

No set and no distinct. Counting unique users is not implemented yet. Record a userId to an event and use uniq() in your query.

Released under the MIT License.