Monitoring
Monitoring
@spfn/monitor provides DB-backed error tracking, developer log storage, and an admin dashboard for SPFN applications.
Features
- Error Tracking — Automatic fingerprint-based deduplication, status management (active/resolved/ignored)
- State-Based Notifications — Slack alerts only on new or reopened errors (no in-memory throttling)
- Developer Logging — Write and query structured logs via DB with pluggable storage backends
- Admin Dashboard — React components for error list, detail view, log viewer, and statistics
- Admin API — RESTful routes for error/log management (superadmin only)
Installation
pnpm add @spfn/monitor drizzle-orm@1.0.0-rc.4
@spfn/core, @spfn/auth and @spfn/notification are peers you already have; next is an
optional peer, needed only for the dashboard components.
Create the tables (error_groups, error_events, logs in the spfn_monitor schema):
pnpm spfn db push # dev: push the schema directly
pnpm spfn db generate && pnpm spfn db migrate # production: with migration history
Server Configuration
Mount monitorRouter as a package router on your app router:
// src/server/router.ts
import { defineRouter } from '@spfn/core/route';
import { monitorRouter } from '@spfn/monitor/server';
export const appRouter = defineRouter({ /* your routes */ })
.packages([monitorRouter]);
// 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();
Package routes are served but stay out of AppRouter's client types — call them through
monitorApi (see API Client Usage), not the generated api client.
Environment Variables
| Variable | Default | Description |
|---|---|---|
SPFN_MONITOR_SLACK_WEBHOOK_URL |
— | Slack webhook URL for error notifications |
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 to track |
The retention values are read by getErrorRetentionDays() / getLogRetentionDays(), but the
package ships no purge scheduler — nothing deletes old rows on its own. Schedule
getLogStore().purge(date) or logsRepository.deleteOlderThan(date) yourself.
All four can also be set in code with configureMonitor({ ... }) from @spfn/monitor/config,
which takes precedence over the environment.
Error Tracking
How It Works
- An error occurs in a route handler
createMonitorErrorHandler()generates a fingerprint (SHA-256(name:message:path), first 16 hex chars)- Checks if an error group with this fingerprint exists:
- New error — Creates group + event, sends Slack notification
- Active/Ignored — Increments count + creates event (no notification)
- Resolved — Reopens to active, increments count + creates event, sends Slack notification
Error Handler Options
createMonitorErrorHandler({
// Only track 5xx errors (default: 500)
minStatusCode: 500,
// Shown in the Slack notification title, e.g. "[production]"
environment: process.env.NODE_ENV,
// Attach custom metadata to each error event
extractMetadata: (err, ctx) => ({
serverInstance: process.env.HOSTNAME,
}),
});
Manual Error Tracking
import { trackError } from '@spfn/monitor/server';
try
{
await riskyOperation();
}
catch (error)
{
await trackError(error as Error, {
statusCode: 500,
path: '/internal/operation',
method: 'POST',
});
}
Status Management
import { updateErrorGroupStatus } from '@spfn/monitor/server';
// Resolve an error group
await updateErrorGroupStatus(groupId, 'resolved');
// Ignore an error group
await updateErrorGroupStatus(groupId, 'ignored');
// Reopen an error group
await updateErrorGroupStatus(groupId, 'active');
Developer Logging
Writing Logs
import { writeLog, monitor } from '@spfn/monitor/server';
// Full API
await writeLog({
level: 'info',
message: 'User signed up',
source: 'auth',
userId: '123',
metadata: { method: 'email' },
});
// Convenience shorthand
await monitor.log({
level: 'warn',
message: 'Rate limit approaching',
source: 'api-gateway',
});
Querying Logs
import { queryLogs } from '@spfn/monitor/server';
const logs = await queryLogs({
level: 'error',
source: 'payment',
search: 'timeout',
dateFrom: new Date('2024-01-01'),
limit: 50,
});
Custom Log Store
Replace the default DB store with a custom backend:
import { setLogStore, type LogStore } from '@spfn/monitor/server';
class S3LogStore implements LogStore
{
async write(entry) { /* ... */ }
async query(filters) { /* ... */ }
async purge(olderThan) { /* ... */ }
}
setLogStore(new S3LogStore());
Admin API
All routes require superadmin role.
| Method | Path | Description |
|---|---|---|
| GET | /_monitor/admin/errors |
List error groups (filter: status, path, search, dateRange) |
| GET | /_monitor/admin/errors/:id |
Error group detail + recent 20 events |
| PATCH | /_monitor/admin/errors/:id |
Update status (resolve/ignore/reopen) |
| GET | /_monitor/admin/errors/:id/events |
Error events with pagination |
| GET | /_monitor/admin/logs |
Query logs (filter: level, source, search, dateRange) |
| GET | /_monitor/admin/stats |
Dashboard statistics |
API Client Usage
import { monitorApi } from '@spfn/monitor';
// Get stats
const stats = await monitorApi.getStats.call({});
// List active errors
const errors = await monitorApi.listErrors.call({
query: { status: 'active', limit: 20 },
});
// Resolve an error
await monitorApi.updateErrorStatus.call({
params: { id: 42 },
body: { status: 'resolved' },
});
Dashboard Components
Full Dashboard
// app/admin/monitor/page.tsx
import { MonitorDashboard } from '@spfn/monitor/nextjs/client';
export default function MonitorPage()
{
return <MonitorDashboard />;
}
Individual Components
import {
StatsOverview,
ErrorListView,
ErrorDetailView,
LogViewer,
} from '@spfn/monitor/nextjs/client';
// Stats cards
<StatsOverview />
// Error list with filters
<ErrorListView onSelect={(id) => router.push(`/admin/monitor/errors/${id}`)} />
// Error detail with event timeline
<ErrorDetailView errorId={42} onBack={() => router.back()} />
// Searchable log viewer
<LogViewer />
All components use Tailwind CSS with dark mode support.
Migration from createErrorSlackNotifier
If you're using createErrorSlackNotifier from @spfn/notification, switch to createMonitorErrorHandler:
// Before
import { createErrorSlackNotifier } from '@spfn/notification/server';
middleware: { onError: createErrorSlackNotifier({ minStatusCode: 500 }) }
// After
import { createMonitorErrorHandler } from '@spfn/monitor/server';
middleware: { onError: createMonitorErrorHandler({ minStatusCode: 500 }) }
Key differences:
- Errors are persisted in DB (survives server restarts)
- Notifications based on state transitions, not time-based throttling
- Admin dashboard for viewing and managing errors
- Developer logging API included
Statistics
The getMonitorStats() function returns:
interface MonitorStats
{
errors: {
total: number;
active: number;
resolved: number;
ignored: number;
};
recentErrors: ErrorGroup[]; // Latest 10 active errors
logs: {
total: number;
byLevel: Record<LogLevel, number>;
};
trends: {
errorsLast24h: number;
errorsLast7d: number;
logsLast24h: number;
};
}