Skip to content

log ​

A log is an event with three reserved columns: ts, level and message. Underneath it stages, batches, claims and flushes exactly as an event does, and on top it adds a severity level, a filter that drops the noisy half before it costs anything, an Error overload that puts the stack somewhere queryable, and a child logger that carries context.

ts
import { log, str } from 'metrichouse/core'

export const appLog = log('app_log', {
  fields: { service: str(), requestId: str().optional() },
  minLevel: 'info',
  flush: '30s',
  write: async (rows) => clickhouse.insert({ table: 'app_log', values: rows }),
})
ts
appLog.info('server started', { service: 'checkout' })
appLog.warn('payment processor slow', { service: 'checkout' })
appLog.error(new Error('card declined'), { service: 'checkout' })
Importimport { log } from 'metrichouse/core'
AnswersWhat did the application say, and how serious was it
StorageStaged whole, never merged
Row{ id, ts, level, message, error_stack?, ...fields, _ingested_at }
Write withthe level methods, at(), child()
Read withpending(), peek(), snapshot()
Use it forApplication messages, request lines, audit trails

Logs matter most in the minute a process is dying, which is why they share the event pipeline rather than arriving with a second transport, a second cadence and second crash behaviour to reason about.

log() ​

ts
log<F, L>(name: string, config: LogConfig<F, L>): Log<F, L>

Declares a log. It is inert until a house binds it, and writing to an unbound log throws.

ParameterTypeRequiredMeaning
namestringyesThe metric name
config.fieldsshapenoExtra columns beyond the reserved ones
config.levelsarray of stringsnoThe closed set of levels
config.minLevelone of levelsnoThe lowest level kept
config.stage'driver' or 'local'noWhere lines wait
config.batchobjectnoLocal staging size and age limits
config.flushdurationnoThe fastest this may ship
config.claimLimitnumbernoLines one claim, and one call to write, may carry
config.writefunctionyesWhere the rows go

name ​

ts
log('app_log', { ... })

A non empty string, unique inside a house.

fields ​

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

Extra columns, alongside the reserved three. Every field type is legal, including json().

ts
fields: {
  service: str(),
  env: str().default('production'),
  requestId: str().optional(),
}

A field may not be named id, ts, level, message, error_stack, _ingested_at or _sample_rate, and the check runs at declaration. fields covers the argument in full.

levels ​

ts
levels?: readonly string[]      // default: ['debug', 'info', 'warn', 'error']

The closed set of levels, in ascending severity. The order is what minLevel compares on, so it is a declaration rather than a formality.

Each level becomes a method, and the type narrows to exactly what you list.

ts
export const auditLog = log('audit_log', {
  fields: { actorId: str(), action: str() },
  levels: ['routine', 'sensitive', 'critical'],    // ascending severity
  minLevel: 'routine',
  flush: '10s',
  write: toS3('audit'),
})

auditLog.sensitive('permissions changed', { actorId: 'u_1', action: 'grant_admin' })

auditLog.info('...')
//       ^^^^ Type error: this log has no 'info' method
ts
import { DEFAULT_LOG_LEVELS } from 'metrichouse/core'
// ['debug', 'info', 'warn', 'error']

Three things throw at declaration: an empty set, a level declared twice, and a level that would shadow a method on the logger. levels: ['debug', 'drain'] is the third one, and catching it here is better than having logger.drain() quietly write a log line. A level named then is refused for the same reason: a then method makes the logger look like a promise, and await on anything that resolves to it would write a line and never finish.

minLevel ​

ts
minLevel?: L[number]      // default: the lowest declared level

Drops anything below this level before it reaches the driver.

ts
export const appLog = log('app_log', {
  fields: { service: str() },
  minLevel: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
  flush: '30s',
  write: toClickHouse('app_log'),
})

// In production this costs one array index and returns.
appLog.debug('request headers', { service: 'api' })

Dropped means dropped. No record is created, no field is validated and nothing is queued, so a filtered call costs one comparison.

A minLevel that is not one of the declared levels throws at declaration.

stage ​

ts
stage?: 'driver' | 'local'      // default: 'driver'

Where lines wait between the call and your write function. Identical to the event's.

Prefer 'driver' on a durable driver. A crash is the moment the last thirty seconds of logs are worth the most, and local staging is the one setting that would lose them.

batch ​

ts
batch?: { maxSize?: number; maxAge?: DurationInput; maxStaged?: number }
// defaults: { maxSize: 500, maxAge: '10s', maxStaged: 100_000 }

Local staging only, and ignored when stage: 'driver'. Identical to the event's, including the cap on how many lines this process holds.

flush ​

ts
flush?: DurationInput      // default: the house default, then '30s'

The fastest this log may ship. A log has no resolution for a cadence to divide, so any duration is legal.

claimLimit ​

ts
claimLimit?: number      // default: unlimited

How many lines one claim, and so one call to write, may carry. Identical to the event's: a flush claims again while a claim comes back full. Worth setting on a log that uploads a file per call.

write ​

ts
write: (rows: LogRow<F, L>[], context: WriteContext) => Promise<void> | void

Where the rows go. Required.

ParameterTypeMeaning
rowsLogRow<F, L>[]The reserved columns, your declared fields, and level typed to the levels you declared
contextWriteContextWhich metric, the span of line timestamps, how many, and which attempt

MetricHouse prints nothing of its own. A write function that also calls console.log is how you get both, and Also writing to the console shows one.

Level methods ​

ts
info(message: string | Error, fields?: InferShape<F>): void

One method per declared level. With the default levels that is debug(), info(), warn() and error().

ParameterTypeRequiredMeaning
messagestring or ErroryesThe line. An Error also fills error_stack
fieldsthe declared shapewhen any field is requiredThe values for this line
ts
appLog.debug('cache miss', { service: 'api' })
appLog.info('order placed', { service: 'api' })
appLog.warn('retrying upstream', { service: 'api' })
appLog.error('upstream unavailable', { service: 'api' })

The fields argument may be left out once nothing in the shape is still required, which is what makes log.info('started') legal on a log whose fields are all optional or bound by a child().

Returns nothing. A line below minLevel returns immediately and stages nothing.

Throws, for a line at or above minLevel, on an unbound log, or on a field that is unknown, missing or ill typed. A line below minLevel is dropped before any of those checks, so it never throws, bound or not. A field named after a column the log writes itself, such as error_stack, throws too, whether the call passes it or a child() bound it. The message itself never throws, which is the next section.

unknown field "userId". The declared fields are [service, requestId]
app_log: "error_stack" is a column the log writes itself, so a line cannot pass it as a field

An Error as the message ​

Any level accepts an Error in place of a string. The message goes in message and the stack goes in the reserved error_stack column.

ts
try {
  await chargeCard(order)
} catch (error) {
  appLog.error(error as Error, { service: 'checkout', requestId })
}
ts
{
  level: 'error',
  message: 'card declined',
  error_stack: 'Error: card declined\n    at chargeCard (...)',
}

An error made in another realm, inside vm, a worker or an iframe, counts as an Error too, and keeps its stack. An error that arrives without a stack, which happens with some rethrown errors, still gets a header line, because that is worth more than an empty column. So does one whose stack is not a string. A message that is not a string is turned into one.

A logger never takes down a request

Every other call in MetricHouse throws on a bad value. A log message does not. Something that is neither a string nor an Error is turned into a string. A value String() cannot convert, such as an object made with Object.create(null), is written as [object Object], and one that cannot be described at all, such as a revoked proxy, as [a value that cannot be printed]. This call is made from inside catch blocks, where the value caught can be anything at all, and a logger that throws there replaces the error you were handling.

log.at() ​

ts
at(level: L[number], message: string | Error, fields?: InferShape<F>): void

Writes at a level chosen while the program runs: one parsed from an upstream payload, or carried in a variable.

ts
appLog.at(level, 'message from upstream', { service: 'gateway' })

Throws when level is not one of the declared levels. A log line with a level nothing queries is worse than a loud failure at the one call site that could have a typo.

log.child() ​

ts
child(fields: Partial<InferShape<F>>): ChildLog

Returns a logger that merges fields into every call it makes. It is how a request id reaches every line without being threaded through every function.

ts
const requestLog = appLog.child({ service: 'checkout', requestId: 'req_9f21' })

requestLog.info('checkout opened')
requestLog.warn('processor slow')
requestLog.error(new Error('declined'))
// every line carries service and requestId

Children nest, and each one adds to what its parent bound.

ts
const serviceLog = appLog.child({ service: 'checkout' })
const requestLog = serviceLog.child({ requestId: 'req_9f21' })

requestLog.info('checkout opened')   // carries service and requestId
requestLog.bound                     // { service: 'checkout', requestId: 'req_9f21' }

A bound field becomes omittable rather than absent in the child's type, so child({ service }) satisfies a required field and a call site may still override it. Only a key the argument is sure to hold counts: from an object typed Partial<...>, where any key may be missing, every required field stays required at the call site. Passing a field as undefined at the call site does not count as an override: the bound value stays. child() drops a field passed to it as undefined in the same way, so it binds nothing and a parent's value stays.

child() copies the fields it is given. Changing that object afterwards changes nothing the child writes.

ts
requestLog.info('retrying', { requestId: 'req_9f22' })   // the call site wins

A child carries the same level methods, at() and child(), plus bound, which is a frozen copy of every field it merges in, including the ones its parents bound.

MemberTypeMeaning
namestringThe log this child writes into
boundReadonly<Record<string, unknown>>The fields merged into every call

A child is not a metric. It stages into the log that made it and shares its batch settings, its cadence and its write function.

log.pending() ​

ts
pending(): Promise<number>

How many lines are staged and not yet shipped.

ts
await appLog.pending()     // 128

log.peek() ​

ts
peek(n?: number): Promise<Row[]>

The first n staged lines as rows, without consuming them. Identical to the event's.

ts
await appLog.peek(20)

log.snapshot() ​

ts
snapshot(options?: SnapshotOptions): Promise<LogLiveRow<F, L>[]>

Unshipped lines as typed rows, with level narrowed to the levels you declared.

ts
await appLog.snapshot({ limit: 100 })
await appLog.snapshot({ from: Date.now() - 60_000, orderBy: 'ts', direction: 'asc' })

Never partial, for the reason an event gives. Options are in Snapshot options.

log.flush() ​

ts
flush(options?: FlushOptions): Promise<MetricFlushReport>

Claims the staged lines and ships them, in claims of at most claimLimit lines. Flush options covers the argument and the report.

log.drain() ​

ts
drain(): Promise<void>

Resolves once every line written before the call has reached the driver. On a locally staged log it also ships what is buffered, and waits for that send. Under immediate delivery it also waits for the send to write that follows each one.

log.rowShape() ​

ts
rowShape(): RowShape
ts
appLog.rowShape().columns.map((c) => c.name)
// ['id', 'ts', 'level', 'message', 'error_stack', 'service', 'requestId',
//  '_ingested_at']

Properties ​

PropertyTypeValue
namestringThe name it was declared with
kind'log'
storage'staged'It keeps each line whole
fieldsShapeThe fields you declared. The reserved three are not among them
levelsreadonly string[]The declared levels, lowest severity first
minLevelstringThe lowest level kept
stage'driver' or 'local'Where lines wait
flushMsnumberflush, parsed, including one taken from the house
isBoundbooleantrue once a house has registered it
writeWriteFnThe function it was declared with

The row ​

ts
{
  id: '019278b4-3c21-7a4e-9f10-8c2d5b6e1a03',
  ts: Date,
  level: 'error',
  message: 'card declined',
  error_stack: 'Error: card declined\n    at ...',   // only when an Error was passed
  service: 'checkout',                               // your declared fields
  requestId: 'req_9f21',
  _ingested_at: Date,
}
ColumnTypeMeaning
idstringA UUID version 7, minted when the line was written
tsDateWhen the line was written
levelone of levelsThe severity
messagestringThe line
error_stackstringPresent only on a line written with an Error
one per fieldas declaredYour values
_ingested_atDateWhen the call ran

Column order is id, ts, level, message, error_stack, then your fields, then _ingested_at. A log never samples, so there is no _sample_rate.

Inside write, each row is a LogRow<F, L>, with level typed to the levels you declared. See Rows are typed.

Table schema ​

sql
CREATE TABLE app_log (
  id            String,
  ts            DateTime64(3),
  level         LowCardinality(String),
  message       String,
  error_stack   Nullable(String),
  service       LowCardinality(String),
  requestId     Nullable(String),
  _ingested_at  DateTime64(3)
)
ENGINE = MergeTree
ORDER BY (ts, level)
TTL toDateTime(ts) + INTERVAL 30 DAY;
sql
CREATE TABLE app_log (
  id            TEXT PRIMARY KEY,
  ts            TIMESTAMPTZ NOT NULL,
  level         TEXT NOT NULL,
  message       TEXT NOT NULL,
  error_stack   TEXT,
  service       TEXT NOT NULL,
  "requestId"   TEXT,
  _ingested_at  TIMESTAMPTZ NOT NULL
);
CREATE INDEX ON app_log (ts DESC);
CREATE INDEX ON app_log (level, ts DESC);

Reserved names ​

ts
import { RESERVED_LOG_COLUMNS } from 'metrichouse/core'
// ['id', 'ts', 'level', 'message', 'error_stack', '_ingested_at', '_sample_rate']

A declared field may not take one of those names. A level may not shadow a method on the logger. Both are checked at declaration. A line that passes one of those names as a field throws when it is written, so error_stack only ever holds the stack of an Error message.

Queries ​

sql
-- Error rate by service, per hour.
SELECT
  toStartOfHour(ts) AS hour,
  service,
  countIf(level IN ('error', 'fatal')) AS errors,
  count()                              AS lines
FROM app_log
WHERE ts >= now() - INTERVAL 1 DAY
GROUP BY hour, service
ORDER BY errors DESC;

-- Every line for one request.
SELECT ts, level, message, error_stack
FROM app_log
WHERE requestId = 'req_9f21'
ORDER BY ts;

Patterns ​

A request scoped logger ​

ts
// metrics/schema.ts
import { log, str } from 'metrichouse/core'

const LEVELS = ['debug', 'info', 'warn', 'error', 'fatal'] as const
type Level = (typeof LEVELS)[number]

// Validate the environment variable here, so a typo is a startup failure with
// a clear message rather than a log that silently drops everything.
function configuredLevel(): Level {
  const value = process.env.LOG_LEVEL ?? 'info'
  if (!LEVELS.includes(value as Level)) {
    throw new Error(`LOG_LEVEL must be one of ${LEVELS.join(', ')}, got ${value}`)
  }
  return value as Level
}

export const appLog = log('app_log', {
  fields: {
    service: str(),
    env: str().default(process.env.NODE_ENV ?? 'development'),
    requestId: str().optional(),
    userId: str().optional(),
    route: str().optional(),
  },

  levels: LEVELS,
  minLevel: configuredLevel(),

  // Durable staging, so a crash does not take the last thirty seconds of logs
  // with it. This is the one signal that matters most while a process is dying.
  stage: 'driver',
  flush: '10s',

  // Bound, so an outage produces several uploads rather than one enormous one.
  claimLimit: 5_000,

  write: async (rows) => {
    const ndjson = rows.map((row) => JSON.stringify(row)).join('\n')
    const day = new Date().toISOString().slice(0, 10)

    await s3.send(
      new PutObjectCommand({
        Bucket: process.env.LOG_BUCKET!,
        Key: `app_log/${day}/${crypto.randomUUID()}.ndjson`,
        Body: ndjson,
        ContentType: 'application/x-ndjson',
      }),
    )
  },
})
ts
// middleware/logger.ts
import { randomUUID } from 'node:crypto'
import { appLog } from '../metrics/schema.js'

declare module 'express-serve-static-core' {
  interface Request {
    log: ReturnType<typeof appLog.child>
  }
}

export function requestLogger(req, res, next) {
  const requestId = req.header('x-request-id') ?? randomUUID()

  // One child per request. Every line it writes carries this context. The
  // route is not in it, because routing has not run yet and req.route is empty.
  req.log = appLog.child({ service: 'api', requestId })

  res.setHeader('x-request-id', requestId)

  res.on('finish', () => {
    // The route is known now, and the mount path is added so a router mounted
    // at /orders does not collapse into '/:id'.
    const route = req.route ? (req.baseUrl ?? '') + req.route.path : 'unmatched'
    const userId = req.auth?.userId

    if (res.statusCode >= 500) {
      req.log.error('request failed', { route, userId })
    } else if (res.statusCode >= 400) {
      req.log.warn('request rejected', { route, userId })
    } else {
      req.log.info('request completed', { route, userId })
    }
  })

  next()
}
ts
// Anywhere downstream.
app.post('/orders', async (req, res) => {
  req.log.info('creating order')

  try {
    const order = await createOrder(req.body)
    req.log.info('order created', { userId: order.userId })
    res.json(order)
  } catch (error) {
    req.log.error(error as Error)     // message and stack both land as columns
    res.status(500).json({ error: 'could not create order' })
  }
})

Also writing to the console ​

MetricHouse prints nothing. In development you usually want both.

ts
function withConsole(name: string) {
  return async (rows: Record<string, unknown>[]) => {
    if (process.env.NODE_ENV !== 'production') {
      for (const row of rows) {
        console.log(`[${row.level}] ${row.message}`, row)
      }
    }
    await toS3(name)(rows)
  }
}

Printing at the call site is the other option, and it gives immediate feedback while MetricHouse handles storage.

Playground ​

The filter ​

minLevel is the setting with the largest effect on what a log costs. Drag it and watch both the volume and the bill.

Try it: what minLevel actually costs you

2.4k lines a minute, before filtering debug 2kinfo 400 kept: 434 a minute dropped before validation: 2k a minute A dropped line costs one array index. No record is built, no field is checked.
debugcache lookup for user u_4821dropped
inforequest completedstored
debugconnection returned to pooldropped
warnpayment processor slowstored
infoorder createdstored
errorcard declined by issuerstored
625klines stored per day
200.0 MBstorage per dayuncompressed
6.0 GBstorage per monthset a TTL on the table
2.9Mdropped per daynever leaves your process
The declaration these settings produce
log('app_log', {
  fields: { service: str() },
  levels: ['debug', 'info', 'warn', 'error'],
  minLevel: 'info',
  flush: '10s',
  write: async (rows) => s3.put(rows),
})

Moving minLevel from debug to info in a busy service usually removes most of the volume, and each dropped line costs one array index.

Staging and batching ​

Underneath, a log stages and batches exactly like an event, so the same controls apply.

Try it: where records wait, and what moves them

stage
in the driver, or in this process
waiting in the driver about 3k records per batch flushyour write() every 30s
Records go to the driver and wait. Whatever calls flush() carries them, no more often than every 30s.
8.6Mrows stored per dayafter sampling
2.9kwrites per daycalls to your database
3krecords per writeone insert this size
The declaration these settings produce
log('app_log', {
  fields: { /* ... */ },
  stage: 'driver',
  flush: '30s',
  write: async (rows) => db.insert(rows),
})

Released under the MIT License.