Declaring a metric
A metric declaration says what you are measuring, how it should be labelled, and where the finished rows go. This page covers the declaration itself. The arguments that appear on more than one type have pages of their own: dims, fields, Field types and Durations.
The shape of a declaration
Every metric takes a name and a configuration object.
import { counter, oneOf, str } from 'metrichouse/core'
export const httpRequests = counter('http_requests', {
dims: {
route: str(),
method: oneOf(['GET', 'POST', 'PUT', 'DELETE']),
status: oneOf(['2xx', '3xx', '4xx', '5xx']),
},
resolution: '10s',
flush: '1m',
write: async (rows) => clickhouse.insert('http_requests', rows),
})| Part | What it decides |
|---|---|
| The name | What your table and your queries call it. Unique inside a house |
| The labels | dims on a folded type, fields on a record keeping one |
| The time settings | resolution, flush and grace |
write | The one function you write |
Each type adds a few settings of its own, and each metric page lists them one by one. Metric types has the side by side table.
A declaration is inert
counter() opens nothing, starts no timer and touches no driver. It returns an object describing what you want, and a house is what turns that into something that writes.
httpRequests.add({ route: '/checkout', method: 'GET', status: '2xx' })
// Error: not bound to a house. Pass it to createHouse({ schema }) before writingThat is why a schema file is safe to import anywhere, including at module scope on a serverless runtime that re runs it on every cold start.
Mistakes surface at import
Configuration is checked when the module is first imported rather than at the first write. An invalid duration, a type that cannot be used as a label, a resolution that does not divide flush evenly, a reserved field name and a bad default value all throw there.
counter('requests', { resolution: '7s', flush: '1m', write })
// Error: requests: resolution 7s does not divide flush 1m evenlyValidation that runs at startup lists every check and the message it produces.
Two checks cannot run that early, and happen when the house registers the metric instead: a cadence that comes from defaults.flush, and a second metric claiming a name that is taken.
The types come from the declaration
The declaration is an ordinary value, so TypeScript reads it directly. There is no code generation step and no build tool.
httpRequests.add({ route: '/checkout', method: 'POST', status: '2xx' })
httpRequests.add({ route: '/checkout', method: 'POST', status: '200' })
// ^^^^^
// Type error: '200' is not assignable to '2xx' | '3xx' | '4xx' | '5xx'
httpRequests.add({ route: '/checkout', method: 'POST' })
// Type error: property 'status' is missingThe same declaration types the rows your write function receives, and the rows snapshot() returns. Rows are typed follows that through to the sink.
Reading a declaration back
Every metric exposes what it was declared with, which is useful for building a table, checking a deployment, or generating a schema.
httpRequests.name // 'http_requests'
httpRequests.kind // 'counter'
httpRequests.storage // 'bucketed'
httpRequests.resolutionMs // 10000
httpRequests.flushMs // 60000
httpRequests.graceMs // 2000
httpRequests.dims // the declared shape
httpRequests.isBound // true once a house has registered it
httpRequests.rowShape()
// {
// columns: [
// { name: 'id', kind: 'str', optional: false },
// { name: 'bucket_ts', kind: 'ts', optional: false },
// { name: 'route', kind: 'str', optional: false },
// { name: 'method', kind: 'oneOf', optional: false },
// { name: 'status', kind: 'oneOf', optional: false },
// { name: 'value', kind: 'int', optional: false },
// ]
// }rowShape() is the exact column list your write function will receive, in order. It is the honest answer to "what columns does my table need".
Keeping a schema in one module
Keep declarations in one module and the house in another, then import the whole schema module rather than listing metrics by hand.
// metrics/schema.ts
export const httpRequests = counter('http_requests', { /* ... */ })
export const httpLatency = timer('http_latency', { /* ... */ })
export const appLog = log('app_log', { /* ... */ })// metrics/house.ts
import { createHouse } from 'metrichouse/core'
import { ioredis } from 'metrichouse/ioredis'
import { Redis } from 'ioredis'
import * as schema from './schema.js'
export const house = createHouse({
driver: ioredis(() => new Redis(process.env.REDIS_URL!)),
schema,
defaults: { flush: '1m', grace: '5s' },
onError: (error, { metric }) => logger.error({ err: error, metric }, 'metric write failed'),
})The house picks metrics out of the module and ignores every other export, so adding a metric is one export and nothing else.
Where to go next
- dims for labelling a folded metric, and what it costs
- fields for the record keeping types
- Buckets and time for choosing
resolutionandflush - The house for binding a schema to a driver
- Metric types for the page on each type