@commencement.technology/ct-seri-logs
v0.2.0
Published
SERI-structured logging and observability for Node.js and NestJS: Serilog-style message templates, page/API request logs on daily-rotated MySQL tables, retention, and the reporting and chart queries to go with them.
Downloads
104
Readme
@commencement.technology/ct-seri-logs
SERI-structured logging and observability for Node.js, Express, Fastify and NestJS.
This is a port of the .NET/Serilog SERI logging structure, not a generic logger. It keeps the four log streams the reference system defines, in the same tables, with the same rotation and retention behaviour — and adds the reporting and chart queries that go with them.
| Stream | Table | What it holds |
| --- | --- | --- |
| Application logs | serilog | Templates, messages, exceptions, properties |
| Page requests | webrequestlog + _1…_7 | Browser traffic, keyed by VisiterID |
| API requests | webservicelog + _1…_7 | Device/API traffic, keyed by DeviceID |
| Device locations | networklocationlog | Network location fixes |
Install
pnpm add @commencement.technology/ct-seri-logs mysql2mysql2, pg, express, fastify, axios, @nestjs/common and @nestjs/core are optional
peers — install only the ones you use.
Quick start
import { createObservability } from '@commencement.technology/ct-seri-logs';
const observability = createObservability({
serviceName: 'orders-api',
environment: process.env.NODE_ENV,
transports: [
{ type: 'console' },
{
type: 'mysql',
connectionString: process.env.OBSERVABILITY_MYSQL_URL,
isolation: {
applicationDatabaseName: 'orders_app',
observabilityDatabaseName: 'orders_observability'
}
}
],
rotation: { schedule: true },
retention: { schedule: true }
});
await observability.start();
const log = observability.logger('OrdersService');
log.information('Order {OrderId} shipped to {Country}', 4711, 'IN');Message templates
The template is the identity of a log statement; the values are what change. All three parts are
stored separately, so Template groups occurrences and Properties is queryable by field.
log.information('Order {OrderId} shipped to {Country}', 4711, 'IN');| Column | Value |
| --- | --- |
| Template | Order {OrderId} shipped to {Country} |
| Message | Order 4711 shipped to IN |
| Properties | {"OrderId":4711,"Country":"IN", …} |
{@Value} captures structure, {$Value} forces the string form, {Value:f2} and {Value,10}
format and align. See docs/message-templates.md.
The older style still works — a template with no holes plus one object is treated as properties:
log.info('Application started', { port: 3000 });Errors
Pass the exception first, Serilog-style, so the stack is preserved into the Exception column:
try {
await charge(order);
} catch (error) {
log.error(error, 'Could not charge order {OrderId}', order.id);
}Errors get a stable fingerprint — type, normalised message and top frame — so Order 41 not
found and Order 42 not found group together. observability.query.errorGroups() reports by
fingerprint.
HTTP request logging
// Express
app.use(observability.express());
// ... routes ...
app.use(observability.expressErrorHandler());
// Fastify
await app.register(observability.fastifyPlugin());Requests are classified into webrequestlog or webservicelog — by default /api and anything
carrying a device-id header goes to the service stream. Configure it with streams.
Logs written inside a handler automatically carry the request, trace and user ids; nothing needs to be threaded through your call signatures.
app.get('/orders/:id', (req, res) => {
log.information('Loading order {OrderId}', req.params.id); // already correlated
res.json({ id: req.params.id });
});NestJS
import { ObservabilityModule } from '@commencement.technology/ct-seri-logs/nest';
@Module({
imports: [
ObservabilityModule.forRoot({
serviceName: 'orders-api',
transports: [{ type: 'console' }, { type: 'mysql', connectionString: process.env.DB_URL }]
})
]
})
export class AppModule {}That registers the request middleware, a global exception filter, an interceptor that tags events with the controller and handler, and a shutdown hook that flushes the final batch.
See docs/nestjs.md.
Reporting and charts
Every stored procedure in the reference system has an equivalent:
const page = await observability.query.webRequestLogs({
from: '2026-08-14T00:00:00Z',
to: '2026-08-14T23:59:59Z',
statusCode: 500,
pageSize: 50
});
const detail = await observability.query.webRequestLogById(page.rows[0].id, page.rows[0].dateUpdated);
const chart = await observability.query.chart({
name: 'WebRequestLog-byhr',
type: 'data',
date: '2026-08-14'
});Charts return Highcharts-shaped output, so an existing dashboard can call this with the same
{name, type, date, filter} payload it sent to SP_Chart.
See docs/queries-and-charts.md.
Daily rotation and retention
The request tables age out by being renamed, not deleted: nightly, webrequestlog becomes _1,
_1 becomes _2, _7 is dropped, and a fresh live table takes over. Dropping a day's table is
instant; deleting tens of millions of rows is not.
rotation: { schedule: true, runAtUtcHour: 0, retainDays: 7 },
retention: { schedule: true, runAtUtcHour: 1, seriLogDays: 7 }Both jobs claim the day in a bookkeeping table before running, so they are safe with any number of replicas. Run them manually if you prefer an external scheduler:
await observability.maintenance?.runRotation();
await observability.maintenance?.runRetention();See docs/operations.md.
Safe by default
- Request and response bodies are not captured unless you enable them
- Passwords, tokens, keys, card numbers and auth headers are redacted, by name and by value shape
- The queue is bounded; overflow drops rather than growing memory
- Transport failures are retried with backoff, then the sink is tripped out of the rotation
- A logging failure can never throw into your application
- Uncaught exceptions, rejections and
SIGTERMall flush before the process exits
Database setup
Run the migration in a dedicated observability database:
mysql orders_observability < node_modules/@commencement.technology/ct-seri-logs/migrations/mysql/001_initial.sqlStartup fails fast if the application and observability database names match, because rotation renames and drops tables and retention issues bulk deletes. See docs/database-isolation.md.
Health
observability.health();
// { status, queue: { queued, capacity, dropped, highWaterMark }, transports: [...], written, failed }Diagnosing the logger itself
A logger that swallows its own failures looks identical to one that is working. Turn on SelfLog and internal failures are reported:
createObservability({ serviceName: 'orders-api', selfLog: true });Documentation
- Message templates
- Schema and streams
- Queries and charts
- NestJS
- Operations: rotation, retention, tuning
- Differences from the .NET reference
- Database isolation
- Database support
- Migrating from existing logging
License
MIT
