@spfn/core/logger
Transport-based logging for Next.js + SPFN server. One process-wide singleton logger,
five levels, automatic sensitive-data masking, and per-module child loggers. No external
logging library.
Import paths
One entry point. Everything public comes from @spfn/core/logger.
import { logger } from '@spfn/core/logger';
import type { LogLevel, Transport } from '@spfn/core/logger';
logger (and Logger, LogLevel, Transport) is also re-exported from the package
root @spfn/core, so import { logger } from '@spfn/core' works too. Prefer the
@spfn/core/logger subpath for clarity.
Public API (complete)
From @spfn/core/logger (src/logger/index.ts is the only export surface):
logger— the singletonLoggerinstance. This is what you use 99% of the time.Logger— the class. Exported mainly for typing; you do not construct it yourself (the singleton is created infactory.ts). Uselogger/logger.child(...).- Types:
LogLevel,Transport.
Instance methods on logger / any child logger:
logger.debug(message, ...),logger.info(...),logger.warn(...),logger.error(...),logger.fatal(...)— the five level methods (overloaded, see Logging below).logger.child(module: string): Logger— new logger that tags every line with[module=...].logger.close(): Promise<void>— close/flush all transports (graceful shutdown).logger.level: LogLevel— getter for the currently active level (read-only).
No such API — do not use these (they appear in old docs but are not in the code):
createLogger(name)— does not exist. Uselogger.child(name).withLogContext(ctx, fn)/ any AsyncLocalStorage context helper — does not exist. Pass context explicitly as the last argument on each call.- File transport,
LOGGER_FILE_ENABLED,LOG_DIR— there is no file transport. The only transport is console (stdout/stderr).LOG_LEVELenv var — the level is read fromSPFN_LOG_LEVEL/NEXT_PUBLIC_SPFN_LOG_LEVEL, notLOG_LEVEL.- Slack / Email / CloudWatch / Sentry transports — none exist and none are "already configured". The factory wires exactly one
ConsoleTransport.Internal formatter functions (
maskSensitiveData,formatConsole,formatJSON,extractQueryInfo,formatUnhandledRejection, etc.) live informatters.tsbut are not part of the public export surface — don't import them from@spfn/core/logger. In particularformatJSONexists but is not wired into any transport: production output is plain (uncolored) console text, not JSON (see Output format).
Quick Start
import { logger } from '@spfn/core/logger';
logger.debug('Cache miss', { key: 'user:123' });
logger.info('Server started', { port: 3000 });
logger.warn('Retry attempt', { attempt: 3 });
logger.error('Operation failed', error, { userId: 123 }); // error as 2nd arg
logger.fatal('Database unreachable', error);
No initialization, no await, no setup — importing logger is enough. The singleton is
constructed on first import in factory.ts.
Logging: message + (error | context) + context
Each level method (debug/info/warn/error/fatal) shares the same overloads.
The second argument is detected at runtime, so argument order and type matter:
// 1. message only
logger.info('Server started');
// 2. message + context object → rendered as [key=value] pairs
logger.info('Request received', { method: 'POST', path: '/users' });
// 3. message + Error → stack trace (and cause chain) printed
logger.error('Query failed', error);
// 4. message + Error + context → both
logger.error('Query failed', error, { userId: 123, op: 'createUser' });
// 5. printf-style — if message contains a %s/%d/%i/%f/%j/%o/%O/%c token,
// the second arg is substituted via node:util.format (NOT treated as context)
logger.info('Fetching url: %s', requestUrl);
How the second argument is classified (see logWithLevel in logger.ts):
- If
messagecontains a printf token (%s %d %i %f %j %o %O %c %%), the 2nd arg is formatted into the message string — it will not appear as context. - An
Errorinstance → logged as the error (stack +causechain printed). - A non-Error object with a string
stackproperty → treated as an error and wrapped. - A plain object without a
stackproperty → treated as context. - A
string/number/boolean→ wrapped into anError(so{ key: value }context must be an object, not a bare primitive).
This stack-based heuristic means an object you intend as context will be misread as an
error if it happens to carry a string stack field. Prefer the explicit 3-arg form
logger.error(msg, error, context) when you have both.
Child loggers (per-module)
logger.child(name) returns a new Logger that inherits the level/transports and adds a
[module=name] tag to every line. Cheap to create; create one per module.
const dbLogger = logger.child('database');
const apiLogger = logger.child('api');
dbLogger.info('Connection established');
// [2026-06-10 10:30:00.123] [pid=12345] [module=database] (INFO): Connection established
Child loggers do not stack — calling .child('b') on a child created with .child('a')
replaces the module name with b, it does not produce a.b.
Log levels
Five levels, filtered by priority. A log is emitted only when its priority is >= the
configured level (filtering happens before metadata is built, so suppressed
logger.debug(...) calls are cheap).
| Level | Priority | Stream | Use case |
|---|---|---|---|
debug |
0 | stdout | Development diagnostics |
info |
1 | stdout | Normal operations (server start, etc.) |
warn |
2 | stderr | Potential issues, retries |
error |
3 | stderr | Failures needing attention |
fatal |
4 | stderr | Critical / shutdown-level errors |
warn, error, fatal go to stderr; debug, info go to stdout (via
console.error / console.log).
LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'.
Configuration (environment variables)
The level is resolved once, at module load, in factory.ts:
| Variable | Effect | Default |
|---|---|---|
SPFN_LOG_LEVEL |
Minimum level. First choice. | info |
NEXT_PUBLIC_SPFN_LOG_LEVEL |
Fallback level (client-visible in Next.js). Used only if SPFN_LOG_LEVEL is unset. |
info |
NODE_ENV |
production → colorized output off; anything else → colors on. |
(warns if unset) |
SPFN_LOG_LEVEL=debug # show everything
NODE_ENV=production # disable ANSI colors (plain text for log collectors)
- An invalid
SPFN_LOG_LEVELvalue falls back toinfowith a stderr warning. - Because the level is read once at import time, changing
process.env.SPFN_LOG_LEVELat runtime has no effect — set it before the process starts. - The console transport itself is created with level
debugand is always enabled; the effective floor is the logger-level computed fromSPFN_LOG_LEVEL.
Sensitive data masking
Context objects are recursively scanned and any key whose lowercased name contains a
sensitive token is replaced with the literal string ***MASKED*** before output. The
original value is never written to any transport.
logger.info('Login attempt', { username: 'john', password: pw, token: t });
// [username=john] [password=***MASKED***] [token=***MASKED***] (INFO): Login attempt
Masking applies to nested objects and arrays, and circular references render as
[Circular]. Matching is substring + case-insensitive, so userPassword, X-Api-Key,
refresh_token, etc. all match.
Masked key tokens (a key matches if it contains any of these):
password, passwd, pwd, secret, token, apikey, api_key, accesstoken,
access_token, refreshtoken, refresh_token, authorization, auth, cookie,
session, sessionid, session_id, privatekey, private_key, creditcard,
credit_card, cardnumber, card_number, cvv, ssn, pin.
Masking only applies to the context object. It does not scan the
messagestring. Never interpolate a secret into the message —logger.info(\token: ${t}`)` is logged verbatim, unmasked. Put potentially-sensitive data in the context object so the masker can catch it.
Output format
There is exactly one transport (ConsoleTransport) and it always renders the
human-readable console format — colorized when NODE_ENV !== 'production', plain text
otherwise. Layout:
[timestamp] [pid=N] [module=name] [key=value]... (LEVEL): message
[2026-06-10 10:30:00.123] [pid=12345] [module=database] (INFO): Connection established
[2026-06-10 10:30:01.456] [pid=12345] [module=api] [userId=123] (ERROR): Request failed
Error: Connection timeout
at processRequest (/app/src/api.ts:45:11)
Errors are printed on their own line after the message, including the Caused by: cause
chain. In containers (Docker/K8s), set NODE_ENV=production so output is plain text on
stdout/stderr, which the platform collects automatically.
Pitfalls & anti-patterns
createLogger/withLogContextdon't exist. Uselogger.child(name)for scoping and pass context explicitly per call. There is no ambient/async context.- No JSON output.
formatJSONexists in the source but is unused; the only transport emits console text. "Production = JSON logs" (from old docs) is false — production just turns colors off. - Wrong env var. The level comes from
SPFN_LOG_LEVEL(orNEXT_PUBLIC_SPFN_LOG_LEVEL), notLOG_LEVEL. - Level is frozen at import. Mutating
process.env.SPFN_LOG_LEVELafter startup does nothing. Set it in the environment before launching. - Masked value is
***MASKED***, not***. Don't assert on***in tests/parsers. - Secrets in the message are NOT masked. Only the context object is scanned. Keep sensitive values out of the message string; put them in context.
- Context vs error ambiguity. A plain object with a string
stackproperty is treated as an error, not context. Use the 3-arg formlogger.error(msg, error, context)when you have both, and don't put astackkey on a context object. - Bare primitives as 2nd arg become errors.
logger.info('x', 42)logs42as an Error (or printf-substitutes it if the message has a%token) — it is not context. Context must be an object:logger.info('x', { n: 42 }). - Don't import internals.
maskSensitiveData,formatConsole,formatJSON,extractQueryInfo,formatUnhandledRejection,Loggerconstruction, etc. are not the public surface. Use theloggersingleton +child. - No file transport.
LOGGER_FILE_ENABLED/LOG_DIRdo nothing. Route stdout/stderr to your log collector (Loki/CloudWatch/ELK) instead.
Complete example
import { logger } from '@spfn/core/logger';
const dbLogger = logger.child('database');
async function createUser(input: { email: string; password: string }): Promise<void>
{
dbLogger.debug('createUser called', { email: input.email });
// 'password' in context would be auto-masked if included.
try
{
await db.insert(/* ... */);
dbLogger.info('User created', { email: input.email });
}
catch (error)
{
// 3-arg form: message + Error (stack + cause) + extra context
dbLogger.error('User insert failed', error as Error, { email: input.email });
throw error;
}
}
// Graceful shutdown: flush transports
process.on('SIGTERM', async () =>
{
logger.info('Shutting down');
await logger.close();
process.exit(0);
});
Types reference
type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';
interface Transport
{
name: string;
level: LogLevel;
enabled: boolean;
log(metadata: LogMetadata): Promise<void>;
close?(): Promise<void>;
}
LogMetadata (the object passed to a Transport.log; not exported, shown for
implementers) carries timestamp: Date, level, message, optional module,
optional error: Error, and optional context (already masked).
logger.level is a LogLevel getter (read-only).
The Transport interface is public so you could implement a custom transport, but the
factory does not expose a registration hook — there is currently no supported way to add a
transport to the singleton without editing factory.ts.
Related
@spfn/core— package root (re-exportslogger).src/logger/factory.ts— where the singleton + console transport are wired and the level is resolved from env.