npm package: @nems.org/bored-logs
Description: Structured PostgreSQL-backed logging for Next.js — custom logger, adapter architecture, React UI components, and DB migration.
Latest version: 0.2.3

# @nems.org/bored-logs

Structured PostgreSQL-backed logging for Next.js — custom adapter-based logger, typed message templates, React UI components, and Kysely migration.

## Contents

- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Database setup](#database-setup)
- [Setup](#setup)
- [Using the logger](#using-the-logger)
- [Secure values](#secure-values)
- [Server actions](#server-actions)
- [Log search](#log-search)
- [UI components](#ui-components)
  - [LogTable](#logtable)
  - [LogSearchBar](#logsearchbar)
  - [LogSearchSyntaxHelp](#logsearchsyntaxhelp)
  - [PurgeLogsDialog](#purgedialog)
  - [Composing components](#composing-components)
- [Optional: encryption](#optional-encryption)
- [Optional: log levels](#optional-log-levels)
- [Optional: process hooks](#optional-process-hooks)

---

## Prerequisites

Peer dependencies required in your Next.js application:

```bash
npm install kysely pg react
```

`pg` and `kysely` are only required if you are using `PostgresAdapter`. They are not loaded in browser or Edge runtimes.

---

## Installation

```bash
npm install @nems.org/bored-logs
```

Add the package to `serverExternalPackages` in your `next.config.ts` so Next.js does not attempt to bundle it through webpack:

```typescript
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  serverExternalPackages: ["@nems.org/bored-logs"],
};

export default nextConfig;
```

---

## Database setup

### 1. Create a Kysely instance

Use `createLoggerPool` for Azure-friendly connection pool defaults (`max: 2`, short idle timeout):

```typescript
// src/lib/db.ts
import { Kysely, PostgresDialect } from "kysely";
import { createLoggerPool } from "@nems.org/bored-logs/adapters/psql";

export const db = new Kysely<any>({
  dialect: new PostgresDialect({
    pool: createLoggerPool({ connectionString: process.env.DATABASE_URL }),
  }),
});
```

Or pass your own `pg.Pool` directly if you have one already.

### 2. Run the migration

Call `migrate()` on your `PostgresAdapter` instance once at startup or in a migration script. No tracking table is used — migrations are idempotent (`CREATE TABLE IF NOT EXISTS`) so it is safe to call on every startup.

```typescript
import { PostgresAdapter } from "@nems.org/bored-logs/adapters/psql";
import { db } from "@/lib/db";

const adapter = new PostgresAdapter({ db });
await adapter.migrate();
```

Roll back one step with `rollback()`:

```typescript
await adapter.rollback();
```

Check which migrations have run with `migrationStatus()`:

```typescript
const status = await adapter.migrationStatus();
// [{ name: "001_logs", applied: true }]
```

Alternatively, import the standalone functions if you are running migrations outside of the adapter lifecycle:

```typescript
import { up, down } from "@nems.org/bored-logs/adapters/psql/migration";

await up(db);   // create tables
await down(db); // drop tables
```

---

## Setup

Create a logger instance in a shared module. `createLogger` is runtime-agnostic — safe to import anywhere.

```typescript
// src/lib/logger.ts
import { createLogger, ConsoleAdapter } from "@nems.org/bored-logs";

export const logger = createLogger({
  application: process.env.APP_NAME,
  version: process.env.APP_VERSION,
});

logger.addAdapter(new ConsoleAdapter({ level: process.env.CONSOLE_LOG_LEVEL ?? "info" }));
```

Add the `PostgresAdapter` in `instrumentation.ts` via dynamic import so that `pg`/`kysely` are only loaded in the Node.js runtime:

```typescript
// src/instrumentation.ts
import { logger } from "@/lib/logger";
import { db } from "@/lib/db";

export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    const { PostgresAdapter } = await import("@nems.org/bored-logs/adapters/psql");

    logger.addAdapter(
      new PostgresAdapter({
        db,
        level: process.env.LOG_DB_LEVEL ?? "info",
        onWarning(w) {
          if (w.type === "attr_keys_truncated") {
            console.error("[bored-logs] attribute keys truncated", w);
          } else if (w.type === "attr_value_truncated") {
            console.error("[bored-logs] attribute value truncated", w);
          }
        },
      }),
    );
  }
}
```

### `createLogger` options

| Option | Type | Default | Description |
|---|---|---|---|
| `level` | `string` | `"debug"` | Global minimum threshold — records below this are never dispatched |
| `application` | `string` | — | Attached to every log record |
| `version` | `string` | — | Attached to every log record |
| `bufferLimit` | `number` | `500` | Max records buffered before first adapter is registered |

### `PostgresAdapter` options

| Option | Type | Default | Description |
|---|---|---|---|
| `db` | `Kysely<any>` | required | Kysely instance with the logger tables |
| `level` | `string` | `process.env.LOG_DB_LEVEL \|\| "info"` | Adapter-level filter |
| `encrypt` | `(plaintext: string) => Buffer` | — | Encrypts attribute values at rest |
| `decrypt` | `(ciphertext: string) => string` | — | Required when `encrypt` is provided |
| `maxConnections` | `number` | `2` | Max concurrent DB operations |
| `onWarning` | `(w: AdapterWarning) => void` | — | Called when an attribute key or value is truncated |

---

## Using the logger

Import your logger instance and call it anywhere on the server.

Message templates use `{key}` placeholders. TypeScript enforces that every placeholder key is present in the attributes object. Extra keys are always allowed.

```typescript
import { logger } from "@/lib/logger";

logger.info("User {userId} signed in", { userId: "u_123" });
logger.warn("Rate limit approaching", { remaining: 5 });
logger.error("Payment failed", { orderId: "ord_456", error: err });

// Extra keys beyond the template placeholders are fine
logger.info("Order {orderId} placed", { orderId: "o_1", amount: 49.99, currency: "USD" });

// Custom levels
logger.log("request", "Incoming request", { method: "GET", path: "/api/data" });
logger.log("sql", "Query executed", { duration: 42 });
logger.log("critical", "Database unreachable");
```

### Log levels

| Level | Number | Use for |
|---|---|---|
| `silent` / `critical` | 0 | Suppress all / fatal errors |
| `error` | 1 | Errors |
| `warn` | 2 | Warnings |
| `info` | 3 | General info |
| `http` / `verbose` / `cache` | 4 | HTTP, verbose, cache events |
| `request` / `response` | 5 | Request/response pairs |
| `sql` | 6 | Database queries |
| `debug` | 7 | Debug output |

### Adjusting levels at runtime

The logger's `level` is a global minimum threshold. Each adapter also has its own `level` property for finer control.

```typescript
// Global threshold — records below this never reach any adapter
logger.level = "debug";

// Per-adapter level — only affects that adapter
for (const adapter of logger.adapters) {
  if (adapter instanceof ConsoleAdapter) adapter.level = "warn";
  if (adapter instanceof PostgresAdapter) adapter.level = "info";
}
```

---

## Secure values

Wrap individual attribute values — or an entire message template — with `secure()` to mark them for encryption at rest. The console adapter always redacts secure values as `[secure]`.

```typescript
import { logger, secure } from "@/lib/logger"; // re-export secure from your lib, or import directly
import { secure } from "@nems.org/bored-logs";

// Secure individual attribute values
logger.info("Sensitive event", { ssn: secure("123-45-6789"), userId: "u_1" });

// Secure the entire message template (whole message stored encrypted)
logger.info(secure("SSN submitted {ssn}"), { ssn: "123-45-6789" });
```

Encryption only takes effect when `encrypt`/`decrypt` are provided to `PostgresAdapter`. Without them, secure values are stored as plaintext but are still redacted from console output.

---

## Server actions

Call `adapter.query()` and `adapter.purge()` directly from your own server actions. Wrap them to add authentication and role checks.

```typescript
// src/actions/logs.ts
"use server";

import type { LogQueryOptions } from "@nems.org/bored-logs";
import { logger } from "@/lib/logger";
import { auth } from "@/lib/auth";
import { requireRole } from "@/lib/utils/permissions";

export async function queryLogs(options?: LogQueryOptions) {
  const session = await auth();
  requireRole(["admin"], session);
  const result = await logger.queryAdapter().query(options ?? {});
  if (!result.ok) throw new Error(result.err.message);
  return result.val;
}

export async function purgeLogs(until: string, limit?: number) {
  const session = await auth();
  requireRole(["admin"], session);
  return logger.queryAdapter().purge(new Date(until), limit); // Result<number, string>
}
```

### `query` options

| Option | Type | Default | Description |
|---|---|---|---|
| `start` | ISO string | 24 hours ago | Start of time range |
| `end` | ISO string | now | End of time range |
| `level` | string | all levels | Filter by log level |
| `message` | string | — | Substring match on the message |
| `limit` | number | 250 (max 1000) | Number of rows to return |
| `offset` | number | 0 | Pagination offset |
| `sort` | `"asc" \| "desc"` | `"desc"` | Sort direction |
| `attributeFilters` | `AttributeFilter[]` | — | Structured attribute filters |

### `purge` options

`purge(until: Date, limit?: number): AsyncResult<number, string>`

Deletes up to `limit` records with `logged_timestamp <= until`. Call repeatedly to page through a large backlog. Use `deepPurge` to remove everything in one pass.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `until` | `Date` | required | Delete records on or before this timestamp |
| `limit` | `number` | `10 000` (max `10 000`) | Maximum records deleted per call. Omitting runs a pre-count; returns `Err` if more than `10 000` records match |

### `deepPurge` options

`deepPurge(until: Date, opts?: { timeoutMs?: number }): AsyncResult<number, string>`

Deletes all matching records with no record-count limit. Uses a single `DELETE … USING` per table to avoid loading IDs into memory — suitable for large historical purges.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `until` | `Date` | required | Delete records on or before this timestamp |
| `opts.timeoutMs` | `number` | `0` | Postgres `statement_timeout` for the transaction in ms. `0` = no timeout |

### `AttributeFilter`

```typescript
type AttributeFilter = {
  key: string;
  value: string;
  operator: "=" | "contains" | ">" | ">=" | "<" | "<=";
  negated?: boolean;
};
```

---

## Log search

`parseLogQuery` converts an Elasticsearch-style query string into `LogQueryToken[]` that map directly to `AttributeFilter[]`. Use `findContradictions` to detect impossible filter combinations before hitting the database.

### Syntax

| Expression | Meaning |
|---|---|
| `bare word` | message contains |
| `key:'value'` | attribute contains |
| `key:='value'` | attribute exact match |
| `key:>'value'` | attribute `>` value |
| `key:>='value'` | attribute `>=` value |
| `key:<'value'` | attribute `<` value |
| `key:<='value'` | attribute `<=` value |
| `key:!'value'` | attribute does NOT contain |
| `key:!='value'` | attribute does NOT equal |
| `'key with spaces':'value'` | quoted key |

Multiple tokens are ANDed together. Keys and values accept single or double quotes.

```typescript
import { parseLogQuery, findContradictions } from "@nems.org/bored-logs";

const tokens = parseLogQuery("level:='error' request_id:'abc'");
// [
//   { key: "level",      operator: "=",        value: "error" },
//   { key: "request_id", operator: "contains", value: "abc"   },
// ]

// Detect impossible combinations before querying
const contradictions = findContradictions(
  parseLogQuery("level:='error' level:!='error'")
);
// contradictions.length === 1
```

Convert tokens to `LogQueryOptions`:

```typescript
const msgTokens  = tokens.filter(t => t.key === "message");
const attrTokens = tokens.filter(t => t.key !== "message");

const options: LogQueryOptions = {
  message: msgTokens.map(t => t.value).join(" "),
  attributeFilters: attrTokens.map(({ key, operator, value, negated }) => ({
    key, operator, value, negated,
  })),
};
```

---

## UI components

All components are **style-less and composable** — no class names are applied internally. Style via the `className` prop on the root element or target the `data-*` attributes provided for each meaningful element. Components are standalone; compose them yourself.

Import from the dedicated entry point to preserve the `"use client"` boundary:

```typescript
import {
  LogTable,
  LogSearchBar,
  LogSearchSyntaxHelp,
  PurgeLogsDialog,
} from "@nems.org/bored-logs/components";
import type { LogQueryToken, SortState, ExtraColumn } from "@nems.org/bored-logs/components";
```

### `LogTable`

A standalone table for displaying log rows. Does not include a search bar or purge dialog — compose those yourself.

```tsx
"use client";

import { useState } from "react";
import { LogTable } from "@nems.org/bored-logs/components";
import type { LogRow } from "@nems.org/bored-logs";
import type { SortState } from "@nems.org/bored-logs/components";

export default function LogsPage() {
  const [logs, setLogs] = useState<LogRow[]>([]);
  const [sort, setSort] = useState<SortState>({ column: "timestamp", direction: "desc" });

  return (
    <LogTable
      logs={logs}
      sort={sort}
      onSortChange={setSort}
      extraColumns={[
        { key: "request_id", label: "Request ID" },
        { key: "service" },
      ]}
      footer={<button onClick={() => {}}>Load more</button>}
    />
  );
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| `logs` | `LogRow[]` | `[]` | Rows to display |
| `sort` | `SortState` | — | Controlled sort state (`{ column, direction }`) |
| `onSortChange` | `(sort: SortState) => void` | — | Called when a header is clicked; enables sortable headers |
| `extraColumns` | `ExtraColumn[]` | `[]` | Additional columns beyond the built-in timestamp/level/message |
| `footer` | `ReactNode` | — | Content rendered in a `<tfoot>` row spanning all columns |
| `className` | `string` | — | Applied to the root `<table>` (or `<p>` when empty with no footer) |
| `theadClassName` | `string` | — | Applied to `<thead>` |
| `tfootClassName` | `string` | — | Applied to `<tfoot>` |

**Built-in columns**: `timestamp`, `level`, `message`. Each `<th>` has a `data-column` attribute; active sort column also has `data-sort="asc"|"desc"`. Level cells have `data-level={log.level}`.

#### `ExtraColumn`

```typescript
type ExtraColumn = {
  key: string;                                      // column id and default header label
  label?: string;                                   // override header label
  value?: (log: LogRow) => unknown;                 // custom value accessor (defaults to log.meta[key])
  render?: (value: unknown, log: LogRow) => ReactNode; // custom cell renderer
};
```

Meta keys are read via `log.meta[key]` by default. Use `value` to surface top-level fields or computed values:

```tsx
extraColumns={[
  // meta key
  { key: "request_id", label: "Request ID" },
  // top-level field via value accessor
  { key: "id", label: "ID", value: (log) => log.id },
  // custom renderer
  {
    key: "level",
    label: "Badge",
    value: (log) => log.level,
    render: (value) => <span className={`badge badge-${value}`}>{String(value)}</span>,
  },
]}
```

### `LogSearchBar`

Tokenised search bar with optional autocomplete. Parses the query syntax described in [Log search](#log-search) and emits `LogQueryToken[]` on each committed token.

```tsx
import { LogSearchBar } from "@nems.org/bored-logs/components";
import type { LogRow } from "@nems.org/bored-logs";

<LogSearchBar
  logs={logs}           // enables key/operator/value autocomplete
  onSearch={(tokens) => { /* tokens: LogQueryToken[] */ }}
  placeholder="level:'error'  request_id:'abc'"
/>
```

| Prop | Type | Default | Description |
|---|---|---|---|
| `onSearch` | `(tokens: LogQueryToken[]) => void` | — | Called with all active tokens after each commit or removal |
| `logs` | `LogRow[]` | — | When provided, enables autocomplete from real key/value data |
| `placeholder` | `string` | example query | Input placeholder shown when no tokens are active |
| `hidden` | `boolean` | `false` | Renders nothing when `true` |
| `className` | `string` | — | Applied to the root `<div>` |

**Autocomplete behaviour** (requires `logs` prop):

- Typing a partial key shows matching key suggestions (including `level`, `message`, `timestamp` always).
- After `key:`, operator suggestions appear (`'`, `='`, `!'`, `!='`, `>'`, `>='`, `<'`, `<='`).
- After `key:'`, value suggestions show unique values for that key from `logs`.
- **Tab** cycles through suggestions; **Enter** accepts the highlighted suggestion (or commits the token if none selected); **Escape** dismisses suggestions for the current stage.
- Escape on key stage: suppresses suggestions while still typing the key; suggestions resume when `:` is typed.
- Escape on value stage: suppresses suggestions for that value; resets when the token is committed.
- Operator stage suggestions are never suppressed by Escape.

Active filter tokens appear as chips before the input. Click `×` on a chip or press **Backspace** on an empty input to remove the last token. A clear-all `×` button appears when there is any input or active token.

### `LogSearchSyntaxHelp`

Standalone syntax reference component — place it anywhere in your layout as a tooltip or help text.

```tsx
import { LogSearchSyntaxHelp } from "@nems.org/bored-logs/components";

<LogSearchSyntaxHelp className="my-tooltip" />
```

Renders a `<span data-log-search-syntax-help>` containing a `<dl>` with operator syntax entries. Style freely via `className` or the `data-log-search-syntax-help` attribute.

### `PurgeLogsDialog`

Standalone purge confirmation dialog. Wire it to your own server action.

```tsx
import { PurgeLogsDialog } from "@nems.org/bored-logs/components";
import { purgeLogs } from "@/actions/logs";

<PurgeLogsDialog onPurge={purgeLogs} />
```

| Prop | Type | Required | Description |
|---|---|---|---|
| `onPurge` | `(until: string, limit?: number) => Promise<Result<number, string>>` | yes | Server action called with an ISO datetime string |
| `toast` | `(message: string) => void` | no | Called with a success/error message after purge |
| `className` | `string` | no | Applied to the root dialog wrapper |

### Composing components

```tsx
"use client";

import { useState } from "react";
import {
  LogSearchBar,
  LogSearchSyntaxHelp,
  LogTable,
  PurgeLogsDialog,
} from "@nems.org/bored-logs/components";
import type { LogRow } from "@nems.org/bored-logs";
import type { LogQueryToken, SortState } from "@nems.org/bored-logs/components";
import { purgeLogs, queryLogs } from "@/actions/logs";

export default function LogsPage() {
  const [logs, setLogs] = useState<LogRow[]>([]);
  const [sort, setSort] = useState<SortState>({ column: "timestamp", direction: "desc" });

  async function handleSearch(tokens: LogQueryToken[]) {
    const msgTokens  = tokens.filter(t => t.key === "message");
    const attrTokens = tokens.filter(t => t.key !== "message");
    const result = await queryLogs({
      message: msgTokens.map(t => t.value).join(" "),
      attributeFilters: attrTokens,
      sort: sort.direction,
    });
    if (result.ok) setLogs(result.val);
  }

  return (
    <div>
      <LogSearchSyntaxHelp />
      <LogSearchBar logs={logs} onSearch={handleSearch} />
      <PurgeLogsDialog onPurge={purgeLogs} />
      <LogTable
        logs={logs}
        sort={sort}
        onSortChange={setSort}
        extraColumns={[{ key: "request_id", label: "Request ID" }]}
      />
    </div>
  );
}
```

---

## Optional: encryption

Provide `encrypt` and `decrypt` to `PostgresAdapter` to store attribute values encrypted at rest. The interpolated `message` field is never encrypted; use `secure()` on the template to encrypt the whole message.

```typescript
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";

const KEY = Buffer.from(process.env.LOG_ENCRYPTION_KEY!, "hex"); // 32 bytes

function encrypt(plaintext: string): Buffer {
  const iv = randomBytes(16);
  const cipher = createCipheriv("aes-256-cbc", KEY, iv);
  return Buffer.concat([iv, cipher.update(plaintext, "utf-8"), cipher.final()]);
}

function decrypt(ciphertext: string): string {
  const buf = Buffer.from(ciphertext, "base64url");
  const iv = buf.subarray(0, 16);
  const decipher = createDecipheriv("aes-256-cbc", KEY, iv);
  return decipher.update(buf.subarray(16)) + decipher.final("utf-8");
}

// Pass to PostgresAdapter in instrumentation.ts
new PostgresAdapter({ db, encrypt, decrypt });
```

---

## Optional: log levels

Configure adapter log levels via environment variables or directly at runtime:

| Variable | Adapter | Default |
|---|---|---|
| `CONSOLE_LOG_LEVEL` | console | `"info"` |
| `LOG_DB_LEVEL` | database | `"info"` |

```typescript
// Runtime adjustment
logger.level = "debug";                    // global threshold
consoleAdapter.level = "warn";             // console only
postgresAdapter.level = "info";            // database only
```

---

## Optional: process hooks

Register cleanup handlers for process lifecycle events using `logger.on()`. The logger flushes and closes before calling your callback so no records are lost on exit. Handlers are chained — `logger.on()` returns `this`.

```typescript
import { logger } from "@/lib/logger";

logger
  .on("SIGINT",  async () => { /* cleanup after logger flushes */ })
  .on("SIGTERM", async () => { })
  .on("beforeExit", async () => { })
  .on("uncaughtException",  async (err)    => { })
  .on("unhandledRejection", async (reason) => { });
```

Safe to call in browser and Edge runtimes — silently ignored when `process` is not available.