Superfunction

@spfn/monitor

DB-persisted error tracking with fingerprint deduplication and state-based Slack notifications, a pluggable developer-log store, superadmin admin routes, and ready-made React dashboard components. Integrates into an SPFN server via defineServerConfig() (onError middleware + lifecycle) and a package router mounted with .packages([...]).

Install

pnpm add @spfn/monitor

Peer dep: next (^15 || ^16, optional — only needed for the nextjs/client components). Workspace deps @spfn/core, @spfn/auth, @spfn/notification come transitively.

Import paths

There are four entry points (from package.json exports):

// Client-safe: API client + shared types/consts
import { monitorApi, type MonitorRouter, type MonitorStats } from '@spfn/monitor';

// Server-only: router, integrations, services, repos, entities (uses node:crypto, DB)
import { monitorRouter, createMonitorErrorHandler, writeLog } from '@spfn/monitor/server';

// Config: code-level overrides + env getters + schema
import { configureMonitor, getMonitorConfig } from '@spfn/monitor/config';

// React components ('use client') — Next.js only
import { MonitorDashboard } from '@spfn/monitor/nextjs/client';

Importing @spfn/monitor/server runs @spfn/monitor/config as a side effect (env registry is built). Never import /server or /config's env-touching code into client/edge bundles.


Setup (SPFN server)

Three integration points, all on defineServerConfig() + the app router:

// src/server/server.config.ts
import { defineServerConfig } from '@spfn/core/server';
import {
    createMonitorErrorHandler,
    createMonitorLifecycle,
} from '@spfn/monitor/server';
import { appRouter } from './router';

export default defineServerConfig()
    .middleware({ onError: createMonitorErrorHandler() })
    .routes(appRouter)
    .lifecycle(createMonitorLifecycle())
    .build();

Mount monitorRouter as a package router on your app router (so its /_monitor/admin/* routes are served, but stay out of AppRouter's client types — call them via monitorApi):

// src/server/router.ts
import { defineRouter } from '@spfn/core/route';
import { monitorRouter } from '@spfn/monitor/server';
import { authRouter } from '@spfn/auth/server';

export const appRouter = defineRouter({ /* your routes */ })
    .packages([authRouter, monitorRouter]);

monitorRouter is built with defineRouter({...}) — there is no mergeRouters export in @spfn/core/route. Use .packages([monitorRouter]), not mergeRouters(...).

Database migration

monitorSchema = createSchema('@spfn/monitor') → the PostgreSQL schema spfn_monitor with tables error_groups, error_events, logs.

pnpm spfn db push                       # dev: push schema directly
# or, with migration history (production):
pnpm spfn db generate && pnpm spfn db migrate

@spfn/monitor ships pre-generated SQL under migrations/ (declared via package.json spfn.migrations.dir); SPFN's CLI picks up the package's entities (spfn.schemas).


Configuration

Environment variables

All optional. Resolution order is code config > env > built-in default.

Variable Default Description
SPFN_MONITOR_SLACK_WEBHOOK_URL Slack webhook for error notifications (falls back to @spfn/notification default)
SPFN_MONITOR_ERROR_RETENTION_DAYS 90 Days to retain error events
SPFN_MONITOR_LOG_RETENTION_DAYS 30 Days to retain logs
SPFN_MONITOR_MIN_STATUS_CODE 500 Minimum HTTP status code tracked as an error

The webhook URL is a secret — keep it in .env.server (server-only), never a committed file.

Code config

import { configureMonitor } from '@spfn/monitor/config';

configureMonitor({
    slackWebhookUrl: process.env.SPFN_MONITOR_SLACK_WEBHOOK_URL, // override
    errorRetentionDays: 90,
    logRetentionDays: 30,
    minStatusCode: 500,
});

MonitorConfig keys: slackWebhookUrl?, errorRetentionDays?, logRetentionDays?, minStatusCode?. Getters resolve config > env > default: getMonitorConfig(), getSlackWebhookUrl(), getErrorRetentionDays(), getLogRetentionDays(), getMinStatusCode(). The validated env proxy is exported as env, the schema as monitorEnvSchema.

Retention values are exposed config, but this package does not ship a purge scheduler. LogStore.purge(olderThan) and logsRepository.deleteOlderThan(date) exist; wire up your own cron/job if you want automatic retention enforcement.


Error tracking

createMonitorErrorHandler(options?) returns an onError callback (signature (err: Error, ctx) => Promise<void>) — a drop-in replacement for createErrorSlackNotifier from @spfn/notification. On each error at/above minStatusCode it calls trackError, which does state-based deduplication:

Existing group state Action Slack?
none (new fingerprint) create group + event yes (new)
active / ignored increment count + create event no
resolved reopen → active, increment, create event yes (reopened)

Errors below minStatusCode are skipped. Slack/event failures are caught and logged — they never break the response.

createMonitorErrorHandler({
    minStatusCode: 500,                 // default: getMinStatusCode() (env or 500)
    environment: process.env.NODE_ENV,  // shown in the Slack title, e.g. "[production]"
    extractMetadata: (err, ctx) => ({   // stored on the error_event row
        serverInstance: process.env.HOSTNAME,
    }),
});

MonitorErrorHandlerOptions: minStatusCode?, environment?, extractMetadata?(err, ctx).

Fingerprinting

generateFingerprint(name, message, path)SHA-256(name:message:path) sliced to the first 16 hex chars. Same name+message+path ⇒ same group. The path component means the same error from different routes is grouped separately.

Manual tracking

import { trackError } from '@spfn/monitor/server';

await trackError(
    error,                              // Error
    {                                   // ErrorTrackingContext
        statusCode: 500,
        path: '/api/example',
        method: 'POST',
        requestId: 'req_123',           // optional
        userId: 'user_42',              // optional, string
        headers: { /* ... */ },         // optional
        query: { /* ... */ },           // optional
        environment: 'production',      // optional
    },
    { custom: 'metadata' },             // optional 3rd arg → error_event.metadata
);

ErrorTrackingContext requires statusCode, path, method; the rest are optional.

Status management

import { updateErrorGroupStatus } from '@spfn/monitor/server';

await updateErrorGroupStatus(groupId, 'resolved');  // sets resolved_at
await updateErrorGroupStatus(groupId, 'ignored');
await updateErrorGroupStatus(groupId, 'active');     // reopen

Throws if the group id does not exist. Statuses: ERROR_GROUP_STATUSES = ['active', 'resolved', 'ignored'] (type ErrorGroupStatus).


Developer logging

import { writeLog, queryLogs, monitor } from '@spfn/monitor/server';

await writeLog({
    level: 'info',                      // LogLevel: debug|info|warn|error|fatal
    message: 'User signed in',
    source: 'auth',                     // optional
    requestId: 'req_123',               // optional
    userId: 'user_42',                  // optional, string
    metadata: { provider: 'google' },   // optional
});

await monitor.log({ level: 'warn', message: 'Rate limit approaching' }); // shorthand → writeLog

const logs = await queryLogs({          // LogFilters
    level: 'error',
    source: 'payment',
    search: 'timeout',                  // ILIKE over message + source
    requestId, userId,
    dateFrom: new Date('2024-01-01'),
    dateTo: new Date(),
    limit: 50,                          // unbounded if omitted
    offset: 0,
});

writeLog / queryLogs return Log / Log[]. LOG_LEVELS = ['debug','info','warn','error','fatal'] (type LogLevel).

Custom log store

Swap the default DB store for any backend implementing LogStore:

import { setLogStore, getLogStore, type LogStore } from '@spfn/monitor/server';

const s3Store: LogStore = {
    async write(entry)     { /* NewLog → Log */ },
    async query(filters)   { /* LogFilters → Log[] */ },
    async purge(olderThan) { /* Date → number deleted */ },
};

setLogStore(s3Store);

setLogStore is process-global and affects writeLog/queryLogs immediately. The default store writes to the logs table. The admin GET /_monitor/admin/logs route also goes through the current store.


Admin API routes

Mounted by monitorRouter. Every route requires authentication and the superadmin role (authenticate + requireRole('superadmin') from @spfn/auth/server).

Method Path Query / body Description
GET /_monitor/admin/errors status, path, search, dateFrom, dateTo, limit(1-100), offset List error groups (limit default 20)
GET /_monitor/admin/errors/:id Group detail + recent 20 events
PATCH /_monitor/admin/errors/:id body { status } Update status (resolve/ignore/reopen)
GET /_monitor/admin/errors/:id/events limit(1-100), offset Events for a group (limit default 20)
GET /_monitor/admin/logs level, source, search, requestId, userId, dateFrom, dateTo, limit(1-100), offset Query logs (limit default 50)
GET /_monitor/admin/stats MonitorStats dashboard aggregate

:id and limit/offset are numbers; dateFrom/dateTo are ISO strings.

API client

import { monitorApi } from '@spfn/monitor';

const stats  = await monitorApi.getStats.call({});
const errors = await monitorApi.listErrors.call({ query: { status: 'active', limit: 20 } });
const detail = await monitorApi.getErrorDetail.call({ params: { id: 42 } });
const events = await monitorApi.listErrorEvents.call({ params: { id: 42 }, query: { limit: 50 } });
const logs   = await monitorApi.listLogs.call({ query: { level: 'error' } });
await monitorApi.updateErrorStatus.call({ params: { id: 42 }, body: { status: 'resolved' } });

monitorApi is built with createApi<typeof monitorRouter> from @spfn/core/nextjs. Route keys: listErrors, getErrorDetail, updateErrorStatus, listErrorEvents, listLogs, getStats.

MonitorStats shape

interface MonitorStats
{
    errors: { total: number; active: number; resolved: number; ignored: number };
    recentErrors: ErrorGroup[];                       // latest 10 active groups
    logs: { total: number; byLevel: Record<LogLevel, number> };
    trends: { errorsLast24h: number; errorsLast7d: number; logsLast24h: number };
}

Dashboard components

'use client' React components from @spfn/monitor/nextjs/client. They fetch through monitorApi (so the admin routes must be served and the caller must be a superadmin) and style with Tailwind CSS (dark-mode aware).

// app/admin/monitor/page.tsx
import { MonitorDashboard } from '@spfn/monitor/nextjs/client';

export default function MonitorPage()
{
    return <MonitorDashboard />;   // no props — tabs (errors/logs) + stats + drill-down
}

Individual components and their props:

Component Props Notes
MonitorDashboard Full dashboard: stats + Errors/Logs tabs + detail drill-down
StatsOverview Error/log count + trend cards
ErrorListView onSelect?: (id: number) => void Filterable error-group table
ErrorDetailView errorId: number, onBack?: () => void Detail + event timeline + status actions
LogViewer Searchable log list with expandable metadata
<ErrorListView onSelect={(id) => router.push(`/admin/monitor/errors/${id}`)} />
<ErrorDetailView errorId={42} onBack={() => router.back()} />

Pitfalls & anti-patterns

  • mergeRouters does not exist. Some older docs/JSDoc show .routes(mergeRouters(appRouter, monitorRouter))@spfn/core/route exports no such function. Mount the monitor router with .packages([monitorRouter]) on your app router.
  • Package routes are excluded from AppRouter client types. After .packages([monitorRouter]), the /_monitor/admin/* routes are served but not on the api client — always call them through monitorApi, not api.
  • Don't import /server or /config into client/edge bundles. /server pulls in node:crypto, DB access, and (via the config side-effect) the env registry. The only browser-safe entry points are @spfn/monitor (client API + types) and @spfn/monitor/nextjs/client (components).
  • onError only fires for thrown/error responses ≥ minStatusCode (default 500). 4xx results that don't throw aren't tracked. Lower minStatusCode (env or option) to widen.
  • Fingerprint includes path. The same error surfacing on two routes becomes two groups; a templated path with embedded ids (/users/123) fragments groups — normalize the path before it reaches tracking if you need them merged.
  • No automatic retention/purge. *_RETENTION_DAYS are just config values read by getters; nothing deletes old rows on its own. Schedule logStore.purge() / logsRepository.deleteOlderThan() (and an equivalent for error events) yourself.
  • No Slack webhook ⇒ silent skip, not an error. notifyErrorToSlack logs a warning and returns when the URL is unset; tracking still persists to the DB.
  • setLogStore is global and unconditional. It replaces the store for the whole process (including the admin log route). There is no per-call store override.
  • userId is a string everywhere here (error_events.user_id, logs.user_id, WriteLogParams.userId). The error handler stringifies the auth context's userId for you; in manual calls pass a string.

Complete example

// src/server/router.ts
import { defineRouter } from '@spfn/core/route';
import { authRouter } from '@spfn/auth/server';
import { monitorRouter } from '@spfn/monitor/server';

export const appRouter = defineRouter({ /* app routes */ })
    .packages([authRouter, monitorRouter]);
// src/server/server.config.ts
import { defineServerConfig } from '@spfn/core/server';
import { createMonitorErrorHandler, createMonitorLifecycle } from '@spfn/monitor/server';
import { configureMonitor } from '@spfn/monitor/config';
import { appRouter } from './router';

configureMonitor({ minStatusCode: 500, logRetentionDays: 14 });

export default defineServerConfig()
    .middleware({
        onError: createMonitorErrorHandler({ environment: process.env.NODE_ENV }),
    })
    .routes(appRouter)
    .lifecycle(createMonitorLifecycle())
    .build();
// app/admin/monitor/page.tsx — superadmin-gated route
import { MonitorDashboard } from '@spfn/monitor/nextjs/client';

export default function MonitorPage()
{
    return <MonitorDashboard />;
}
// anywhere server-side: structured logging
import { monitor } from '@spfn/monitor/server';

await monitor.log({ level: 'info', message: 'job done', source: 'cron', metadata: { took: 120 } });

Exports reference

// '@spfn/monitor' (client-safe)
monitorApi
type MonitorRouter, MonitorStats, ErrorGroupStatus, LogLevel
ERROR_GROUP_STATUSES, LOG_LEVELS

// '@spfn/monitor/server'
monitorRouter
createMonitorErrorHandler, createMonitorLifecycle              // integration
trackError, updateErrorGroupStatus, generateFingerprint       // error tracking
writeLog, queryLogs, setLogStore, getLogStore, monitor        // logging
getMonitorStats                                               // stats
errorGroupsRepository, errorEventsRepository, logsRepository  // repositories
errorGroups, errorEvents, logs, monitorSchema                 // entities
ERROR_GROUP_STATUSES, LOG_LEVELS, monitorLogger
type ErrorTrackingContext, MonitorErrorHandlerOptions, MonitorLifecycleConfig,
     LogStore, WriteLogParams, MonitorStats, ErrorGroupFilters, LogFilters,
     ErrorGroup, NewErrorGroup, ErrorGroupStatus, ErrorEvent, NewErrorEvent,
     Log, NewLog, LogLevel

// '@spfn/monitor/config'
configureMonitor, getMonitorConfig
getSlackWebhookUrl, getErrorRetentionDays, getLogRetentionDays, getMinStatusCode
env, monitorEnvSchema
type MonitorConfig

// '@spfn/monitor/nextjs/client'
MonitorDashboard, StatsOverview, ErrorListView, ErrorDetailView, LogViewer