npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nems.org/bored-logs

v0.3.0

Published

Structured PostgreSQL-backed logging for Next.js — custom logger, adapter architecture, React UI components, and DB migration.

Readme

@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

Peer dependencies required in your Next.js application:

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

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:

// 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):

// 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.

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():

await adapter.rollback();

Check which migrations have run with migrationStatus():

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:

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.

// 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:

// 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.

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.

// 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].

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.

// 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

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.

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:

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:

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.

"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

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:

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 and emits LogQueryToken[] on each committed token.

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.

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.

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

"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.

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" |

// 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.

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.