Online users
A number that exists whether or not you look at it, sampled on a timer, and read back live for a status page.
The metric
// metrics/schema.ts
import { gauge, oneOf, str } from 'metrichouse/core'
import { toClickHouse } from './sinks.js'
export const onlineUsers = gauge('online_users', {
dims: {
region: oneOf(['us-east', 'eu-west', 'ap-south']),
plan: oneOf(['free', 'pro', 'enterprise']),
},
// One row per minute per region per plan. Nine series, 1,440 rows a day each.
resolution: '1m',
flush: '1m',
write: toClickHouse('online_users'),
})A gauge rather than a counter, because this is a value that exists at a moment in time. Counting connections as they open and subtracting as they close would drift the first time a connection dies without a clean close.
The house
// metrics/house.ts
import { createHouse } from 'metrichouse/core'
import { memory } from 'metrichouse/memory'
import * as schema from './schema.js'
export const house = createHouse({
driver: memory(),
schema,
onError: (error, { metric }) => console.error(`[${metric}]`, error),
onWarn: (message) => console.warn('[metrichouse]', message),
})Use ioredis() instead if you run more than one instance and want the numbers to describe the whole fleet.
Sampling
A gauge only has a value when something writes one. Here that is a timer of your own, because six readings a minute is what makes min and max mean something.
// metrics/sampler.ts
import { onlineUsers } from './schema.js'
import { sessions } from '../sessions.js'
export function startSampling() {
// Every ten seconds gives six observations per minute, so min and max are
// meaningful and no minute is ever empty.
const handle = setInterval(async () => {
const counts = await sessions.countActiveByRegionAndPlan()
for (const { region, plan, count } of counts) {
onlineUsers.set(count, { region, plan })
}
}, 10_000)
// Sampling should never be the reason the process stays alive.
handle.unref()
return () => clearInterval(handle)
}When one reading per window is enough, declare the gauge with collect instead. MetricHouse then calls it a second before each minute ends, clears it on house.stop(), and on several servers lets one of them take the reading for all.
Write a zero rather than skipping
If a region has nobody online, write 0 rather than skipping it. A missing observation and a zero look identical in a chart, and only one of them is true.
Wiring it up
// server.ts
import { house } from './metrics/house.js'
import { startSampling } from './metrics/sampler.js'
const server = app.listen(3000)
house.start()
const stopSampling = startSampling()
async function shutdown() {
server.close()
stopSampling()
await house.stop()
process.exit(0)
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)Reading it live
The point of a gauge is that you can read it before it ships.
// routes/status.ts
import { onlineUsers } from '../metrics/schema.js'
app.get('/status', async (_req, res) => {
// One row per series per unshipped minute, oldest minute first, so the last
// row seen for a series holds its newest reading.
const rows = await onlineUsers.snapshot({
complete: false,
orderBy: 'bucket_ts',
direction: 'asc',
})
const latest = new Map<string, number>()
for (const row of rows) {
// `last` is typed optional, because a gauge may leave it out of `aggregate`
if (row.last !== undefined) latest.set(`${row.region}:${row.plan}`, row.last)
}
let onlineNow = 0
for (const count of latest.values()) onlineNow += count
const usEast = await onlineUsers.current({ region: 'us-east', plan: 'pro' })
res.json({
// every series' newest reading, added up
onlineNow,
// current() is undefined when nothing has been observed this minute.
usEastPro: usEast?.last ?? 0,
})
})The number of users online is the sum of each series' newest reading, and no single call returns it. totals() is the wrong call here: its sum adds up every reading taken this minute, so six samples of 150 users report 900, and its max is the largest reading of any one series rather than the peak of the whole. A rollup across series has no last for the same reason totals() has none, so the route keeps the newest reading per series itself. A series whose last minute has shipped and which has not been sampled yet in the new one is left out until its next sample, at most ten seconds with the sampler above.
A breakdown for a small dashboard:
app.get('/status/regions', async (_req, res) => {
const rows = await onlineUsers.snapshot({
complete: false, // include the minute still filling
rollup: 'sum',
groupBy: ['region'],
orderBy: 'max',
direction: 'desc',
})
res.json(rows)
})The table
CREATE TABLE online_users (
id String,
bucket_ts DateTime64(3),
region LowCardinality(String),
plan LowCardinality(String),
last Float64,
min Float64,
max Float64,
sum Float64,
count Int64
)
ENGINE = ReplacingMergeTree
ORDER BY (bucket_ts, region, plan);CREATE TABLE online_users (
id TEXT PRIMARY KEY,
bucket_ts TIMESTAMPTZ NOT NULL,
region TEXT NOT NULL,
plan 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
);
CREATE INDEX ON online_users (bucket_ts, region);Queries
-- Average concurrency per hour, per region.
SELECT
toStartOfHour(bucket_ts) AS hour,
region,
sum(sum) / sum(count) AS avg_online,
max(max) AS peak_online
FROM online_users
WHERE bucket_ts >= now() - INTERVAL 7 DAY
GROUP BY hour, region
ORDER BY hour;-- The busiest minute of each day, for capacity planning.
SELECT
toDate(bucket_ts) AS day,
max(max) AS peak_concurrent_users
FROM online_users
GROUP BY day
ORDER BY day DESC
LIMIT 30;-- How much of the peak comes from enterprise accounts.
SELECT
toStartOfHour(bucket_ts) AS hour,
maxIf(max, plan = 'enterprise') AS peak_enterprise,
max(max) AS peak_total
FROM online_users
WHERE bucket_ts >= now() - INTERVAL 1 DAY
GROUP BY hour
ORDER BY hour;What you get
bucket_ts region plan last min max sum count
2026-09-17 14:03:00 us-east pro 1297 1284 1301 7764 6
2026-09-17 14:03:00 us-east free 4102 4088 4133 24_642 6
2026-09-17 14:03:00 eu-west pro 642 630 651 3852 6Six observations a minute, folded into one row each. Nine series means nine rows a minute, about 13,000 a day, which any database will store for years without noticing.