Structured Logging
Open Saasframe provides a thin logging facade at @saasframe/shared/lib/logger. It gives every module leveled, structured, context-aware logging behind one stable import — backed by pino (structured JSON) on the Node server and a lightweight console transport in the browser and on the edge runtime.
Use it instead of raw console.log / console.warn / console.error in module code.
Quick Start
import { createLogger } from '@saasframe/shared/lib/logger'
const logger = createLogger('events')
logger.debug('Delivering to subscriber', { event, subscriberId })
logger.info('Reindex completed', { entityId, count })
logger.warn('Event payload exceeds size limit, skipping', { event: eventName, maxBytes })
logger.error('Handler error', { event, pattern, err: error })
createLogger(namespace)creates (or reuses — instances are cached per namespace) a logger whose lines carry the namespace: as thenamefield in server JSON, or as a[namespace]prefix in console output.- Four levels:
debug,info,warn,error. - The message is a stable, un-interpolated string; dynamic values go into the optional
fieldsobject so they land as queryable JSON keys instead of being baked into the message text. - Pass an
Errorunder theerrkey. The server transport routes it through pino'serrserializer (type, message, stack); the console transport prints its stack.
On the server, the line above emits structured JSON:
{"level":50,"time":1751966400000,"name":"events","event":"pos.cart.completed","pattern":"pos.*","err":{"type":"Error","message":"boom","stack":"..."},"msg":"Handler error"}
In the browser or on the edge it emits a compact readable line:
[events] Handler error event=pos.cart.completed pattern=pos.* Error: boom
at ...
Context Propagation with child()
child(bindings) returns a logger with the bindings merged into every subsequent line, so shared context is attached once instead of repeated per call. Children can be chained; later bindings override earlier ones on key collision.
A real example from the events SSE stream route, which previously hand-rolled an [events:stream] prefix:
import { createLogger } from '@saasframe/shared/lib/logger'
const logger = createLogger('events').child({ component: 'stream' })
logger.warn('Event payload exceeds size limit, skipping', { event: eventName, maxBytes: MAX_PAYLOAD_BYTES })
Or attaching per-delivery context for a span of related lines:
const log = logger.child({ event, subscriberId: sub.id })
log.debug('Delivering to subscriber')
log.error('Subscriber failed for event', { err: result.reason })
Configuring the Level: SF_LOG_LEVEL
One global environment variable controls the level everywhere — no per-feature debug flags needed:
| Source | Value | Effective level |
|---|---|---|
SF_LOG_LEVEL set | debug | info | warn | error (case-insensitive) | that level |
SF_LOG_LEVEL unset or blank, NODE_ENV=production | — | info |
SF_LOG_LEVEL unset or blank, otherwise (dev/test) | — | debug |
SF_LOG_LEVEL set to an unrecognized value | — | the NODE_ENV default, plus one warning line |
Levels are ordered debug < info < warn < error; a line is emitted when its level is at or above the effective level. The value is read once and memoized for the process lifetime.
SF_LOG_LEVEL=debug yarn dev # verbose diagnostics
SF_LOG_LEVEL=warn yarn start # quiet: warnings and errors only
For expensive field construction, gate the work instead of relying on the logger to drop the line:
import { isLevelEnabled } from '@saasframe/shared/lib/logger'
if (isLevelEnabled('debug')) {
logger.debug('Match trace', { candidates: computeExpensiveTrace() })
}
getLogLevel() returns the resolved level directly when you need it.
Pretty Mode in Development: SF_LOG_PRETTY
Raw pino JSON is right for production aggregation but noisy to read in a terminal. In pretty mode the Node server transport writes one human-friendly line per call instead:
12:05:20.613 INFO [queue] Job completed queue=events jobId=d3e13935-0ccb-4794-ba0a-030872b27fc0
12:05:21.004 ERROR [events:stream] Payload skipped tenantId=t-9 maxBytes=4096
Timestamp, padded level (colored when the output is a TTY), [namespace] (a component binding folds into [namespace:component]), the message, and compact key=value fields; an err Error prints its stack on the following lines.
| Source | Value | Effective mode |
|---|---|---|
SF_LOG_PRETTY set | truthy token (1/true/yes/on) | pretty |
SF_LOG_PRETTY set | falsy token (0/false/off) | raw pino JSON |
unset or unrecognized, NODE_ENV=production | — | raw pino JSON |
| unset or unrecognized, otherwise (dev/test) | — | pretty |
So yarn dev is pretty by default and production stays structured JSON. Set SF_LOG_PRETTY=0 in dev when you want raw JSON — for example when piping local output to Loki/Grafana.
Note for the dev runtime: saasframe server dev spawns the Next.js, worker, and scheduler child processes with NODE_ENV=production, so the facade's NODE_ENV-based dev defaults would not apply there on their own. The dev command therefore injects SF_LOG_PRETTY=1 and SF_LOG_LEVEL=debug into those children unless you set either variable yourself.
Two caveats: pretty mode is a plain formatter, so pino's redaction does not apply — it is a dev convenience, and the "never log credentials/PII" rule is what actually protects you; and it honors SF_LOG_DESTINATION=stderr, so stdout-protocol processes stay safe in dev too.
Routing Logs to stderr: SF_LOG_DESTINATION
By default the server transport writes JSON to stdout. Processes whose stdout is a protocol channel — most notably the MCP stdio server (saasframe ai_assistant mcp:serve, which speaks JSON-RPC over stdout) — must not interleave log lines with protocol frames. Set SF_LOG_DESTINATION=stderr (case-insensitive) to make the pino root write to process.stderr instead; any other value (or unset) keeps the stdout default.
The value is read when the pino root is lazily created on the first log call. If your entrypoint sets it programmatically after other code may already have logged, call resetServerLoggerCache() from @saasframe/shared/lib/logger right after setting the env var — existing loggers re-resolve their transport on the next call. The mcp:serve CLI entrypoint does exactly this.
Isomorphic Behavior
@saasframe/shared is consumed by server, browser, and edge bundles, so the facade selects its transport at runtime:
- Node server — pino, emitting one JSON object per line to stdout (or the compact pretty transport when pretty mode resolves on). Correct for production log aggregation; ops wires the collector.
- Browser (
windowdefined) and edge (NEXT_RUNTIME=edge) — a console shim mapping the four levels toconsole.debug/info/warn/error, printing[namespace], the message, and a compactkey=valuerendering of the merged bindings.
pino never leaks into client bundles: the facade and console transport have zero static dependency on pino, and the server transport loads it lazily at the first server-side log call via a runtime require — never a static import. Merely importing the facade touches nothing pino-related, and any load failure falls back to the console transport. Because the shared package builds unbundled per-file, importing @saasframe/shared/lib/logger pulls in only the logger files.
Why a Facade (and Not pino Directly)
- Isomorphism. pino is a Node library (
worker_threads,process.stdout). Importing it directly from shared code would break browser and edge bundles. The facade picks a console transport client-side and pino server-side behind one import. - Swappability. Call sites must never import
pinodirectly. If the backend is later swapped, or OpenTelemetry bridging, sampling, or richer redaction is added, one file changes — not thousands of call sites. This is the same rationale asapiCallwrappingfetchand the DI-resolved cache wrapping Redis. - Contract stability. The four-method
Loggersurface is small and frozen under the backward-compatibility contract; pino's full API is not something modules should couple to.
Redaction — a Safety Net, Not a Guarantee
The server transport configures pino's built-in redact for the obvious sensitive keys, censoring values to [Redacted]:
password, *.password, token, *.token, secret, *.secret,
authorization, *.authorization, headers.authorization, req.headers.authorization
Treat this strictly as a safety net:
- Wildcards are single-level —
*.tokencatchesuser.tokenbut notpayload.user.token. - The console transport performs no redaction at all, so a sensitive binding prints verbatim in the browser and on the edge.
The standing rule is unchanged: never log credentials, PII, or payload bodies. Log IDs, event names, counts, and error objects. The events reference migration logs only event, pattern, subscriberId, maxBytes, and err — never payloads.
Migrating from console.*
Existing raw console.* calls are migrated incrementally under the Boy Scout rule: when you touch a file, migrate the logging lines you touch. No big-bang rewrite.
The pattern, taken from the events package migration:
// before
console.error(`[events] Handler error for "${event}" (pattern: "${pattern}"):`, error)
// after
import { createLogger } from '@saasframe/shared/lib/logger'
const logger = createLogger('events')
logger.error('Handler error', { event, pattern, err: error })
When migrating:
- Preserve severity —
console.warnbecomeslogger.warn,console.errorbecomeslogger.error. Don't downgrade existing warnings or errors todebug. - Stabilize the message — move interpolated values into
fieldsso the message becomes a constant, queryable string. - Replace hand-built prefixes —
[module]/[module:part]prefixes become the namespace, or achild({ component: '...' })binding. - Errors under
err— pass the caught error asfields.err, not concatenated into the message.
Collapsing per-feature debug flags
Bespoke env flags that exist only to gate diagnostic output (the SF_WORKFLOW_TRIGGER_DEBUG pattern) collapse into logger.debug(...):
// before: a bespoke flag reinventing level gating
if (process.env.MY_FEATURE_DEBUG === '1') {
console.log(`[my-feature] matched ${count} subscribers for ${event}`)
}
// after: gated globally by SF_LOG_LEVEL
logger.debug('Matched subscribers', { event, count })
Run with SF_LOG_LEVEL=debug to see the output; the flag and its documentation disappear.
Console checker (CI-enforced)
yarn logger:check-console reports raw console.* usage in packages/*/src (excluding tests) and exits 0 for local inspection. Since the application-wide migration brought the count to zero, CI runs the blocking variant — yarn logger:check-console:ci (--strict), which fails the lint job on any new raw console.* call. Intentional program output belongs on the allowlist (scripts/logger-console-allowlist.json) with a reason: whole packages under packages (CLI user output) or glob patterns under files (module cli.ts command output). Everything else should use the facade.