SaaS Production Logging: Structured Logs, Centralized Guide

You are debugging a production issue. The user reports an error, but the logs show nothing useful — just a wall of console.log('got here') at various points in the code, none of which fire because the error happens in a different code path entirely. You have no correlation ID, no structured fields to filter on, and no way to find the relevant logs without grepping through a flat file that has been rotating every hour because nobody configured log rotation either.
This scenario is not hypothetical. It is the default logging setup on approximately every SaaS that has not yet had a production incident bad enough to force a real investment in observability. The fix is a deliberate saas production logging structured logs centralized approach: structured JSON logs from Winston, a correlation ID on every request, centralized aggregation in Loki or CloudWatch, and alerting that fires when the error rate spikes — not when a user emails support.
Structured logging is not about making logs prettier. It is about making them queryable at scale. When your SaaS serves a thousand requests per minute, a console.log('payment succeeded') is noise. A structured log entry from a proper saas production logging structured logs centralized system becomes { "level": "info", "correlationId": "abc-123", "tenantId": "t-456", "userId": "u-789", "service": "payments", "message": "payment succeeded", "duration": 342, "timestamp": "2026-06-24T12:00:00Z" } is a query waiting to happen.

Why console.log Does Not Scale
The default NestJS logger writes formatted text to the console. It works on your laptop. In production, text-formatted logs are the wrong data structure. A grep on a single server works for a prototype. Across ten servers with rotated log files, it collapses.
The problems are basic and well-documented:
- No structured fields. You cannot filter by
levelortenantIdwithout regexing the entire log line, and regex over a rotation of files is a losing game. - No correlation IDs. A single user request may hit six services or six handlers. Without a shared ID, you cannot connect those log entries into a coherent trace.
- No log level control in production. The same
debuglogs that are useful during development bloat your production storage and cost you money. - No transport separation. Console logs disappear when the container restarts. File logs grow unbounded. Remote aggregation requires explicit configuration.
Structured logging fixes all of these. The investment is an afternoon of Winston configuration and a few hours setting up Loki or CloudWatch. The payoff is being able to answer "what happened to this user's request across all services" in a single query.
Winston Setup in NestJS
For a saas production logging structured logs centralized setup, Winston is the most widely used logging library in the Node.js ecosystem, and it integrates naturally with NestJS. Install the packages:
1npm install winston nest-winstonCreate a logger factory that produces structured JSON output:
1import * as winston from 'winston';
2import { utilities as nestWinstonModuleUtilities } from 'nest-winston/dist/winston.utilities';
3
4export function createLoggerConfig(environment: string) {
5 const transports: winston.transport[] = [
6 new winston.transports.Console({
7 format: environment === 'production'
8 ? winston.format.json()
9 : winston.format.combine(
10 winston.format.timestamp(),
11 winston.format.ms(),
12 nestWinstonModuleUtilities.format.nestLike('App', { colors: true }),
13 ),
14 }),
15 ];
16
17 if (environment !== 'development') {
18 transports.push(
19 new winston.transports.File({
20 filename: 'logs/error.log',
21 level: 'error',
22 format: winston.format.json(),
23 }),
24 new winston.transports.File({
25 filename: 'logs/combined.log',
26 format: winston.format.json(),
27 }),
28 );
29 }
30
31 return { transports };
32}Register the logger in your application module:
1import { LoggerModule } from 'nest-winston';
2import { createLoggerConfig } from './logger.config';
3
4@Module({
5 imports: [
6 LoggerModule.forRoot(createLoggerConfig(process.env.NODE_ENV || 'development')),
7 ],
8})
9export class AppModule {}Then replace the default NestJS logger at application startup:
1async function bootstrap() {
2 const app = await NestFactory.create(AppModule);
3 app.useLogger(app.get(Logger));
4 await app.listen(3000);
5}The Winston npm documentation covers the full range of formats, transports, and custom levels. For a SaaS production setup, the critical choice is JSON format for production and colorized text for development — one is optimized for machines, the other for humans.
What Every Log Entry Must Include
A structured log entry is a JSON object with predictable keys. In a saas production logging structured logs centralized setup, every entry should include:
1interface LogEntry {
2 timestamp: string; // ISO 8601
3 level: string; // error, warn, info, debug
4 message: string;
5 correlationId: string; // per-request
6 tenantId?: string; // multi-tenant SaaS
7 userId?: string; // authenticated user
8 request: {
9 method: string; // GET, POST, etc.
10 path: string;
11 statusCode: number;
12 duration: number; // ms
13 };
14 service: string; // service or module name
15 environment: string; // production, staging
16}In any saas production logging structured logs centralized architecture, the correlation ID is the single most important field. Every request gets a unique ID at the first point of entry — usually the API gateway or the NestJS middleware — and every downstream service includes it in every log entry.
1import { Injectable, NestMiddleware } from '@nestjs/common';
2import { v4 as uuidv4 } from 'uuid';
3
4@Injectable()
5export class CorrelationIdMiddleware implements NestMiddleware {
6 use(req: any, res: any, next: () => void) {
7 const correlationId = req.headers['x-correlation-id'] || uuidv4();
8 req.correlationId = correlationId;
9 res.setHeader('x-correlation-id', correlationId);
10 next();
11 }
12}For multi-tenant SaaS, include tenantId in every log entry. When a tenant reports an issue, filter by tenantId and see every request that tenant's users made, across all services, in chronological order. Without this field, you are guessing which logs belong to which customer.
Correlation IDs: Tracing Across Services
In a monolithic NestJS application, a single correlation ID traces the request through middleware, guards, interceptors, controllers, services, and TypeORM queries. In a distributed system, the correlation ID must propagate across service boundaries.
When Service A calls Service B via HTTP, it includes the correlation ID as a request header:
1import { Injectable } from '@nestjs/common';
2import { HttpService } from '@nestjs/axios';
3
4@Injectable()
5export class ApiClient {
6 constructor(private http: HttpService) {}
7
8 callServiceB(payload: any, correlationId: string) {
9 return this.http.post('https://service-b.internal/api', payload, {
10 headers: { 'x-correlation-id': correlationId },
11 });
12 }
13}Service B's middleware extracts it and adds it to the log context. If the ID is missing (a request from an external source that does not set it), generate a new one so the entry still has an identity.
For message queues, pass the correlation ID in the message metadata. BullMQ supports this natively in job options. Kafka headers carry it in the record headers. The pattern is always the same: accept the ID on entry, propagate it on exit.
Log Levels: When to Use Each
Log levels are free — you pay nothing to add them and pay real money to store them. Use levels to separate the signal from the noise.
- error. Something is wrong and needs human attention. Database connection failures, payment processing failures, unexpected exceptions. Alert on this level. The error log should include the stack trace and enough context to reproduce the issue.
- warn. Something unusual happened but the system handled it. A retried database query, a deprecated endpoint being called, a rate limit approaching. Log these, investigate if the count spikes, but do not page on them.
- info. Normal operation events that you need for auditing and debugging. User registration, subscription changes, successful payment processing. These are the logs you query when a user says "I changed my plan yesterday but the dashboard still shows the old one."
- debug. Development-only detail. Raw SQL queries, middleware timing breakdowns. Disable these in production unless you are actively debugging a specific issue. Enable them via a feature flag or dynamic log level change — not a redeploy.
Set the production log level to info. When you need to debug a specific request, add a mechanism to temporarily elevate logging for that correlation ID:
1if (this.configService.get('NODE_ENV') !== 'production') {
2 // Always log debug in development
3 logger.level = 'debug';
4} else if (isDebugRequest(req.correlationId)) {
5 // Elevate logging for specific trace IDs in production
6 logger.level = 'debug';
7}The isDebugRequest function checks an in-memory set or a Redis set of correlation IDs that have been flagged for debugging. This lets you debug a single production request without enabling debug logging for every request.
Centralized Saas Production Logging with Structured Logs
Structured logs on disk are better than console.log, but they are still distributed across every server. A saas production logging structured logs centralized approach collects logs from every instance into a single queryable store.
Two options dominate for NestJS SaaS deployments:
Grafana Loki. Loki indexes only labels (service name, environment, level), not the log content itself. The log text is stored in compressed chunks in object storage. This makes Loki significantly cheaper than full-text indexing systems at high log volumes. Logs are queried through Grafana using LogQL, which mirrors PromQL. The Grafana Loki documentation covers deployment modes from single-binary to microservices.
To send NestJS logs to Loki, use a Winston Loki transport or push logs through Promtail:
1import * as LokiTransport from 'winston-loki';
2
3transports.push(
4 new LokiTransport({
5 host: process.env.LOKI_URL || 'http://localhost:3100',
6 labels: { service: 'nestjs-api', environment: process.env.NODE_ENV },
7 json: true,
8 format: winston.format.json(),
9 }),
10);AWS CloudWatch Logs. CloudWatch is the default for AWS-hosted applications. The AWS Winston transport sends logs to CloudWatch Log Groups organized by service and environment. CloudWatch supports subscription filters that can forward logs to Lambda for real-time processing, Elasticsearch for full-text search, or S3 for long-term archival. The AWS CloudWatch Logs documentation covers setup, log group policies, and metric filters for alerting.
The choice depends on your infrastructure. If you already run Grafana for Prometheus metrics, adding Loki is natural. If your application is on AWS ECS or Lambda, CloudWatch is zero-config.
We use Loki on most projects because the cost at SaaS scale (hundreds of GB per day) is predictably lower — Loki's compression ratio on JSON logs is roughly 5:1 to 10:1 against the raw text — and the Grafana integration means dashboards combine metrics and logs in one place.
Alerting on Error Rate Spikes
A log without an alert is a record. A saas production logging structured logs centralized approach turns logs into alerts. A log with an alert is a response. The difference between a user reporting a problem and the system catching it first is a well-configured alert on the error rate.
In Loki, a LogQL alert on error count looks like:
1sum(rate({service="nestjs-api", level="error"}[5m])) > 10This fires when the error rate exceeds 10 errors per second over 5 minutes. The threshold depends on your traffic — for a service handling 100 requests per second, 10 errors per second is 10% error rate, which is catastrophic. For a service handling 5 requests per second, 10 errors per second is impossible (each request generates multiple errors), and the threshold should be lower.
Use the correlation ID count as the denominator for a percentage-based alert:
1(sum(rate({service="nestjs-api", level="error"}[5m]))
2 /
3 sum(rate({service="nestjs-api", level=~"info|error|warn"}[5m])))
4* 100 > 5This tracks error percentage rather than absolute count. It adjusts automatically for traffic variations.
Configure the alert to notify through PagerDuty, OpsGenie, or Slack. Severity rules: error rate above 5% for 5 minutes = page. Error rate above 1% for 15 minutes = Slack notification. Warnings about deprecated endpoints or retry spikes = daily digest email.
Masking Sensitive Data in Logs
The most common logging mistake is logging sensitive data. Passwords in query strings, credit card numbers in request bodies, API tokens in headers — once they reach the log file, they are discoverable by anyone with read access to the logging system. A saas production logging structured logs centralized pipeline must sanitize these before transport.
Winston formats operate on the log entry before it reaches any transport. Add a sanitization format that redacts known sensitive fields:
1const sensitiveFields = ['password', 'secret', 'token', 'authorization',
2 'credit_card', 'ssn', 'api_key'];
3
4const sanitizeFormat = winston.format((info) => {
5 const sanitized = { ...info };
6 if (sanitized.meta) {
7 for (const field of sensitiveFields) {
8 if (sanitized.meta[field]) {
9 sanitized.meta[field] = '[REDACTED]';
10 }
11 }
12 }
13 return sanitized;
14});Whitelist-based logging is more reliable than blacklist-based. Instead of logging the entire request body and redacting specific fields, log only the fields you explicitly include in the log entry. This requires more discipline in the logging calls but eliminates the risk of a new sensitive field appearing in the request body and passing through the redaction filter unnoticed.
Test the redaction periodically. Before a major release, grep the production logs for patterns that look like secrets — long random strings next to keys like "password" or "secret". If anything appears, the redaction needs fixing.
Saas Production Log Retention and Cost Management
Logs grow. They grow fast. A NestJS SaaS handling 50 requests per second generates roughly 10—30 GB of raw JSON logs per day depending on the detail level. Without a saas production logging structured logs centralized retention plan, at that volume, indefinite retention is financially irresponsible.
Define retention policies by log level and environment:
- Error logs: 90 days. Errors are rare and valuable for post-mortem analysis.
- Info logs: 30 days. Normal operations. Useful for debugging, not worth keeping for quarters.
- Debug logs: disabled in production. Do not pay to store data you do not query.
- Staging logs: 7 days. Staging generates noise for validation. No reason to keep it longer.
In Loki, retention is configured on the Compactor:
1limits_config:
2 retention_period: 720h # 30 daysIn CloudWatch, set expiration on each Log Group:
aws logs put-retention-policy --log-group-name /aws/nestjs/production --retention-in-days 30
For long-term archival of compliance-required logs (financial transactions, user data changes), export filtered logs to S3 Glacier or similar cold storage. Keep them accessible for audit queries, but do not keep them in the hot query path.
Monitor your log storage costs. A 50% increase in log volume without a corresponding increase in traffic usually means a loop that logs inside a hot path, a noisy neighbour service, or a misconfigured log level. Investigate before the bill arrives.

Summary
A structured logging setup for a NestJS SaaS — from Winston configuration to centralized aggregation to alerting — is one of those investments where the cost is small and the payoff is continuous. Every production incident you debug with a single {correlationId} query instead of grepping through files validates the entire effort.
- Configure Winston with JSON format in production, a file transport for errors, and a remote transport for saas production logging structured logs centralized aggregation.
- Add a correlation ID middleware that generates, propagates, and logs a unique ID per request. Include
tenantIdanduserIdin every entry. - Send logs to Loki or CloudWatch depending on your infrastructure. Loki is cheaper at scale; CloudWatch is simpler on AWS.
- Alert on error percentage, not raw error count. Use LogQL or CloudWatch Metrics filters.
- Sanitize sensitive fields before they reach the transport. Whitelist-based logging is safer than blacklist-based.
- Set retention policies by log level. 90 days for errors, 30 days for info, 7 days for staging.
This logging setup integrates with broader platform decisions covered in the environment variable management post (log levels as env vars), the Docker multi-stage build post (log volumes mapped to persistent storage), and the audit log post (distinct from application logging).
The next time someone asks you what happened in production, you will not say "let me check the logs" with a tone of dread. You will open the Grafana dashboard, type {correlationId="abc-123"}, and have your answer in three seconds. The first time that happens, the afternoon you spent setting up Winston will feel like the best investment you made all quarter.
Frequently Asked Questions
Use Winston with structured JSON format, a correlation ID per request, and separate log levels for development (console) and production (file + remote aggregation). Send logs to a centralized system like Grafana Loki or AWS CloudWatch. Set log retention to 7-30 days depending on compliance needs and mask sensitive fields before they leave the application.
Install winston and nest-winston. Create a custom logger service implementing LoggerService from @nestjs/common. Configure Winston with JSON format, timestamp, and transports for console and file output. Register the custom logger as the default NestJS logger using app.useLogger(). Add a request-scoped correlation ID that gets included in every log entry.
Every log entry should include: timestamp (ISO 8601), log level, message, correlation ID, tenant ID (for multi-tenant SaaS), user ID (if authenticated), request path and method, response status code and duration, service name, and environment. For structured JSON logging, these are top-level keys that make filtering in Loki or CloudWatch fast.
Loki is cheaper for high-volume logs, works with Grafana dashboards, and is better for teams already using Prometheus. CloudWatch Logs is the default for AWS-hosted applications, integrates natively with ECS and Lambda, and supports subscription filters for real-time processing. Choose Loki for cost efficiency at scale; choose CloudWatch for AWS-native convenience.
Create a Winston format filter that redacts fields matching patterns (password, secret, token, authorization, credit card, SSN) before the log reaches any transport. Use whitelist-based logging — log only what you explicitly need — rather than blacklisting known sensitive fields. Test the redaction by running a log review before every major release.
