> Complete content index: https://sirannon.sondelali.com/llms.txt

# Hooks, metrics, and lifecycle

> Intercept operations with before and after hooks, feed timings into your metrics system, and let the lifecycle manager own multi-tenant database handles.

URL: https://sirannon.sondelali.com/docs/hooks-metrics-and-lifecycle
Section: Core features
Version: 0.3 (@delali/sirannon-db@0.3.0, latest)

Three cross-cutting systems observe and control every database a `Sirannon` instance owns. Hooks intercept operations, metrics callbacks report timings and events, and the lifecycle manager opens and closes database handles for you. This page builds one file, `tenants.ts`, step by step. You will start from a driver and a bare instance, register hooks on it, and then grow the constructor with a metrics block and a lifecycle block. Every step marks the lines that changed.

Each tenant database is a file in a directory you create yourself, so make one before you run the code below.

```bash
mkdir -p data/tenants
```

## Construct the instance

Start `tenants.ts` with a driver and a `Sirannon` instance. Everything else on this page attaches to this instance.

```ts title="tenants.ts"

const driver = betterSqlite3()

const sirannon = new Sirannon({ driver })
```

## Register hooks

Hooks run before or after key operations, so a before-hook that throws denies the operation with `HOOK_DENIED`. Register them on the instance you built. A before-query hook can reject statements, an after-query hook can log timings, and a database-open hook can record which tenant databases open.

```ts title="tenants.ts"

const driver = betterSqlite3()

const sirannon = new Sirannon({ driver })
// [!code ++:14]

sirannon.onBeforeQuery((ctx) => {
  if (ctx.sql.includes('DROP')) {
    throw new Error('DROP statements are not allowed')
  }
})

sirannon.onAfterQuery((ctx) => {
  console.log(`[${ctx.databaseId}] ${ctx.sql} took ${ctx.durationMs}ms`)
})

sirannon.onDatabaseOpen((ctx) => {
  console.log(`Opened ${ctx.databaseId} at ${ctx.path}`)
})
```

Global hooks on the `Sirannon` instance cover `onBeforeQuery`, `onAfterQuery`, `onBeforeConnect`, `onDatabaseOpen`, and `onDatabaseClose`. The context object exposes `databaseId`, `sql`, `durationMs`, and `path`, each one where it applies.

<Warning>
  The `ctx.sql.includes('DROP')` pattern above illustrates the hook and nothing more. Substring matching lets
  different casing, comments, and concatenated SQL straight through, so it can never work as a SQL firewall. For
  real access control, combine `onBeforeQuery` with an allowlist of query patterns or a SQL parser.
</Warning>

## Add metrics callbacks

Hooks give you a place to run code; metrics give you the numbers. Pass a `metrics` block to the constructor to collect query timing, connection events, and CDC activity, and feed each callback straight into your histogram, gauge, and counter types. Keep the constructor call you already have and extend it, so that the hooks you registered above still apply. The `histogram`, `gauge`, and `counter` values here are minimal stand-ins so that the file runs; swap in your real metrics client such as Prometheus or StatsD.

```ts title="tenants.ts"

const driver = betterSqlite3()

const sirannon = new Sirannon({ driver }) // [!code --]
// [!code ++:13]
const histogram = { observe: (ms: number) => {} }
const gauge = { inc: (labels: { db: string }) => {}, dec: (labels: { db: string }) => {} }
const counter = { inc: (labels: { table: string; op: string }) => {} }

const sirannon = new Sirannon({
  driver,
  metrics: {
    onQueryComplete: (m) => histogram.observe(m.durationMs),
    onConnectionOpen: (m) => gauge.inc({ db: m.databaseId }),
    onConnectionClose: (m) => gauge.dec({ db: m.databaseId }),
    onCDCEvent: (m) => counter.inc({ table: m.table, op: m.operation }),
  },
})

sirannon.onBeforeQuery((ctx) => {
  if (ctx.sql.includes('DROP')) {
    throw new Error('DROP statements are not allowed')
  }
})

sirannon.onAfterQuery((ctx) => {
  console.log(`[${ctx.databaseId}] ${ctx.sql} took ${ctx.durationMs}ms`)
})

sirannon.onDatabaseOpen((ctx) => {
  console.log(`Opened ${ctx.databaseId} at ${ctx.path}`)
})
```

Each callback receives one argument. `onQueryComplete` receives `durationMs`, `onConnectionOpen` and `onConnectionClose` receive `databaseId`, and `onCDCEvent` receives `table` and `operation`. Hooks can deny an operation, but metrics only observe, so a slow or failing metrics callback never blocks a query.

## Manage multi-tenant lifecycle

For multi-tenant setups, the lifecycle manager handles auto-opening, idle timeouts, and least-recently-used eviction, so your application calls `resolve` and leaves every handle to the manager. Add a `lifecycle` block alongside `metrics` in the same constructor. The `autoOpen.resolver` maps a tenant id to a file path, `idleTimeout` closes handles after inactivity, and `maxOpen` limits how many are open at once.

```ts title="tenants.ts"

const driver = betterSqlite3()

const histogram = { observe: (ms: number) => {} }
const gauge = { inc: (labels: { db: string }) => {}, dec: (labels: { db: string }) => {} }
const counter = { inc: (labels: { table: string; op: string }) => {} }

const sirannon = new Sirannon({
  driver,
  metrics: {
    onQueryComplete: (m) => histogram.observe(m.durationMs),
    onConnectionOpen: (m) => gauge.inc({ db: m.databaseId }),
    onConnectionClose: (m) => gauge.dec({ db: m.databaseId }),
    onCDCEvent: (m) => counter.inc({ table: m.table, op: m.operation }),
  },
  // [!code ++:7]
  lifecycle: {
    autoOpen: {
      resolver: (id) => ({ path: `./data/tenants/${id}.db` }),
    },
    idleTimeout: 300_000,
    maxOpen: 50,
  },
})

sirannon.onBeforeQuery((ctx) => {
  if (ctx.sql.includes('DROP')) {
    throw new Error('DROP statements are not allowed')
  }
})

sirannon.onAfterQuery((ctx) => {
  console.log(`[${ctx.databaseId}] ${ctx.sql} took ${ctx.durationMs}ms`)
})

sirannon.onDatabaseOpen((ctx) => {
  console.log(`Opened ${ctx.databaseId} at ${ctx.path}`)
})
```

With `autoOpen` configured, `resolve` opens a tenant database on first access through the resolver and reuses the handle after that. Call it with a tenant id wherever your request handler uses the database.

```ts title="tenants.ts"

const driver = betterSqlite3()

const histogram = { observe: (ms: number) => {} }
const gauge = { inc: (labels: { db: string }) => {}, dec: (labels: { db: string }) => {} }
const counter = { inc: (labels: { table: string; op: string }) => {} }

const sirannon = new Sirannon({
  driver,
  metrics: {
    onQueryComplete: (m) => histogram.observe(m.durationMs),
    onConnectionOpen: (m) => gauge.inc({ db: m.databaseId }),
    onConnectionClose: (m) => gauge.dec({ db: m.databaseId }),
    onCDCEvent: (m) => counter.inc({ table: m.table, op: m.operation }),
  },
  lifecycle: {
    autoOpen: {
      resolver: (id) => ({ path: `./data/tenants/${id}.db` }),
    },
    idleTimeout: 300_000,
    maxOpen: 50,
  },
})

sirannon.onBeforeQuery((ctx) => {
  if (ctx.sql.includes('DROP')) {
    throw new Error('DROP statements are not allowed')
  }
})

sirannon.onAfterQuery((ctx) => {
  console.log(`[${ctx.databaseId}] ${ctx.sql} took ${ctx.durationMs}ms`)
})

sirannon.onDatabaseOpen((ctx) => {
  console.log(`Opened ${ctx.databaseId} at ${ctx.path}`)
})
// [!code ++:2]

const db = await sirannon.resolve('tenant-42')
```

The hooks and metrics you configured apply to this handle like any other. Opening it invokes `onDatabaseOpen`, and each query on it invokes `onBeforeQuery` and `onAfterQuery`. When `tenant-42` is idle past `idleTimeout`, the manager closes it; when the open count reaches `maxOpen`, it evicts the least-recently-used database to make room. Reaching the cap with every handle still active raises `MAX_DATABASES`.

The registry also migrates a tenant as the manager auto-opens it. A `migrations` set on the same constructor migrates every tenant the first time the resolver opens it, before `resolve` returns the handle, so a tenant added long after the last schema change still opens on the current schema. The [migrations](/docs/migrations) page covers the registry set, loading it from a function, and how a failed migration leaves a tenant unregistered so that the next open retries it.
