Skip to content

Standard Log Structure

In a microservice architecture, a log entry without context is nearly useless. When a single user action triggers calls across four services, you need a way to stitch those scattered log lines back into a coherent story. This guide defines the structured logging standard that makes that possible.

Every log entry must be a JSON object — not a plain string. JSON logs can be ingested, parsed, and indexed by any observability platform (Datadog, Loki, ELK Stack, CloudWatch) without custom parsers.

// ❌ Unstructured — impossible to query reliably
"User 123 updated record at 10:40:00"
// ✅ Structured — every field is queryable
{ "level": "info", "user_id": "123", "action": "UPDATE_RECORD", "timestamp": "..." }

Knowing what happened is not enough. A log must also tell you where (which service), who (which user), and which request (correlation ID). Without these three anchors, debugging across services is guesswork.

LevelWhen to Use
debugVerbose internal state, useful during development only
infoNormal operational events (request received, record created)
warnUnexpected but recoverable situations
errorFailures that require attention
fatalSystem-level failures requiring immediate intervention

💡 In production, debug logs should be disabled by default and toggled only during active investigation.

Every inbound request should be assigned a correlation_id at the API gateway or the first service that receives it. This ID is then forwarded in all downstream calls and included in every log entry. With it, you can reconstruct the full request lifecycle across all services from a single search.

The following must never appear in any log entry:

  • Passwords or password hashes
  • JWT tokens or API keys
  • Personally Identifiable Information (PII) beyond what is operationally necessary
  • Full credit card or payment data
  • Session secrets

Every log entry across all services must conform to this schema:

{
"timestamp": "2025-09-12T10:40:00.123Z",
"level": "info",
"message": "Resource record updated successfully",
"service": {
"name": "resource-management",
"version": "1.2.0"
},
"trace": {
"trace_id": "abc-123-xyz-789",
"span_id": "span-456",
"correlation_id": "req-1a2b3c4d"
},
"user": {
"id": "usr-007",
"role": "admin"
},
"context": {
"resource_id": "res-123456",
"action": "UPDATE_RESOURCE",
"ip_address": "203.0.113.x"
},
"details": {
"fields_updated": ["address", "phone_number"]
}
}
FieldRequiredDescription
timestampISO 8601 UTC timestamp of when the event occurred
levelSeverity: debug, info, warn, error, or fatal
messageHuman-readable summary of the event
service.nameThe service or bounded context emitting the log (e.g., auth, orders-bc)
service.versionService version — critical for correlating issues to a specific deployment
trace.trace_idUnique ID for the distributed trace (spans the full request lifecycle)
trace.span_idOptionalID of the current operation within the trace
trace.correlation_idID linking all logs from the same originating request
user.idIf availableThe authenticated user’s identifier
user.roleIf availableThe user’s role at the time of the event
contextDomain-specific context: resource IDs, action names, etc.
detailsOptionalAdditional structured data for deeper debugging

🔑 service.name is the most important field for multi-service deployments. It is the primary key for filtering logs by bounded context in any observability platform.


import { Injectable, LoggerService } from '@nestjs/common';
@Injectable()
export class StructuredLoggerService implements LoggerService {
private buildEntry(level: string, message: string, meta: Record<string, unknown>) {
return JSON.stringify({
timestamp: new Date().toISOString(),
level,
message,
service: {
name: process.env.SERVICE_NAME,
version: process.env.SERVICE_VERSION,
},
...meta,
});
}
log(message: string, meta?: Record<string, unknown>) {
console.log(this.buildEntry('info', message, meta ?? {}));
}
error(message: string, meta?: Record<string, unknown>) {
console.error(this.buildEntry('error', message, meta ?? {}));
}
warn(message: string, meta?: Record<string, unknown>) {
console.warn(this.buildEntry('warn', message, meta ?? {}));
}
}

Logging with Correlation ID via Interceptor

Section titled “Logging with Correlation ID via Interceptor”
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
constructor(private readonly logger: StructuredLoggerService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest();
const correlationId = request.headers['x-correlation-id'] ?? randomUUID();
this.logger.log('Incoming request', {
trace: { correlation_id: correlationId },
context: {
method: request.method,
path: request.path,
},
});
return next.handle();
}
}

// ✅ INFO — normal operations
this.logger.log('Order created successfully', {
trace: { correlation_id },
context: { order_id: order.id, action: 'CREATE_ORDER' },
});
// ✅ WARN — recoverable anomaly
this.logger.warn('Payment gateway timeout — retrying', {
trace: { correlation_id },
context: { attempt: retryCount, max_retries: 3 },
});
// ✅ ERROR — operation failed
this.logger.error('Failed to process payment', {
trace: { correlation_id },
context: { order_id: order.id, error_code: err.code },
// ❌ NEVER include: card numbers, tokens, raw error stack with secrets
});
// ❌ WRONG — sensitive data in log
this.logger.log('User logged in', {
context: { password: dto.password, token: jwtToken }, // NEVER DO THIS
});

A well-implemented structured logging system transforms debugging from archaeology into search. The investment is low — a consistent schema and a single interceptor — but the payoff during an incident is enormous: instead of grep-ing through interleaved plain-text output from a dozen services, you run a single query by correlation_id and see the full picture in seconds.