@highwaydelite/logger
v0.1.3
Published
Standardized structured logging for NestJS services (Pino + OpenTelemetry, CloudWatch-ready).
Readme
@highwaydelite/logger
Standardized structured logging for Highway Delite NestJS services.
Built on nestjs-pino + Pino, with
OpenTelemetry trace correlation and a CloudWatch-friendly output format. Every
service should log through @highwaydelite/logger so that field names,
redaction, and correlation behave identically everywhere.
Why this exists
If each team configures Pino itself, formats drift and dashboards break. This package bakes the agreed decisions into one versioned dependency: bump the version and the standard propagates. Do not hand-roll a Pino config in a service.
Install
npm install @highwaydelite/logger
# peer deps (most services already have the Nest ones):
npm install @nestjs/common @nestjs/core nestjs-pino pino @opentelemetry/apiQuick start
// app.module.ts
import { Module } from '@nestjs/common';
import { LoggerModule } from '@highwaydelite/logger';
@Module({
imports: [
LoggerModule.forRoot({ service: 'orders-api' }),
// ...your modules
],
})
export class AppModule {}// main.ts
import { NestFactory } from '@nestjs/core';
import { useSharedLogger } from '@highwaydelite/logger';
import { AppModule } from './app.module';
async function bootstrap() {
// bufferLogs => early bootstrap logs flush through the shared logger
const app = await NestFactory.create(AppModule, { bufferLogs: true });
useSharedLogger(app);
await app.listen(3000);
}
bootstrap();That's it. Incoming requests and completed responses are logged automatically, each line carries the correlation id and (when a span is active) the trace context, and sensitive fields are redacted.
Logging in your code
Inject the typed logger and set the class name as context. Typed application
logs must include operation and phase; extra JSON-safe fields are allowed.
import { Injectable } from '@nestjs/common';
import { InjectTypedLogger, TypedLogger } from '@highwaydelite/logger';
@Injectable()
export class OrderService {
constructor(
@InjectTypedLogger(OrderService.name) private readonly log: TypedLogger,
) {}
async createOrder(dto: CreateOrderDto) {
this.log.debug(
{
operation: 'createOrder',
phase: 'validate',
itemCount: dto.items.length,
},
'validating order payload',
);
// ...
this.log.info(
{ operation: 'createOrder', phase: 'persisted', orderId },
'order persisted',
);
}
}PinoLogger / InjectPinoLogger remain exported as raw compatibility escape
hatches for advanced Pino-specific cases. Prefer TypedLogger for application
logs so schema drift is caught by TypeScript.
Configuration
LoggerModule.forRoot(options) — all options are optional:
| Option | Default | Notes |
|----------------------|----------------------------------------|-------|
| service | process.env.SERVICE_NAME | Service name, e.g. orders-api. |
| env | process.env.NODE_ENV ?? development | Deployment environment. |
| version | process.env.SERVICE_VERSION | Build/version stamp. |
| level | process.env.LOG_LEVEL ?? info/debug | info in prod, debug otherwise. |
| pretty | true outside production | Human-readable console output. Never enable in prod. |
| redact | [] | Added to the baseline redaction; cannot remove it. |
| autoLogging | true | Emits pino-http request lifecycle logs. Set false to disable them globally. |
| excludeRequestLogs | [] | Suppresses automatic request lifecycle logs for matching paths. |
Recommended environment variables per service:
SERVICE_NAME=orders-api
SERVICE_VERSION=1.8.2 # inject your build sha/tag here
NODE_ENV=production
LOG_LEVEL=infoRequest Log Suppression
Use excludeRequestLogs for high-volume probe endpoints such as health checks.
This suppresses only the automatic pino-http lifecycle line; application logs
inside a handler still emit normally.
LoggerModule.forRoot({
service: 'orders-api',
excludeRequestLogs: [
'/api/v1/health',
'/api/v1/ready',
'/api/v1/readiness',
/^\/internal\/probes\//,
(req) => req.headers['user-agent'] === 'ELB-HealthChecker/2.0',
],
});String matchers are exact path matches, ignore query strings, and may be provided with or without the leading slash.
The standard log schema
Every line carries these fields:
| Field | Source | Meaning |
|--------------|-------------------------|---------|
| time | auto (ISO 8601) | Event timestamp. |
| level | auto (string in prod) | trace/debug/info/warn/error/fatal. |
| service | base | Which service emitted the line. |
| env | base | Environment. |
| version | base | Service build/version. |
| reqId | genReqId | Per-request correlation id (see below). |
| trace_id | OTel mixin | Distributed trace id. Present when a span is active. |
| span_id | OTel mixin | Current span id. |
| context | your logger | Class name that emitted the line. |
| operation | typed logger | Method/workflow name that emitted the line. |
| phase | typed logger | Lifecycle point within the operation. |
| msg | you | The event. |
| req/res | auto (lifecycle line) | Method, url, status, response time. |
Field conventions
contextis the class, always. Never putClass.methodhere — keep it consistent sofilter context = "OrderService"always works.operationis required for typed application logs. Use the method or workflow name, e.g.createOrder.phaseis required for typed application logs. Use a short lifecycle point, e.g.validate,persisted, orfailed.- For timing a method, prefer giving it an OTel span named
Class.method. - Do not log request/response bodies by default. Bodies are a PII and a cost risk in CloudWatch. Log them only on errors or behind an explicit debug flag.
reqId vs trace_id
reqIdties together one service's handling of a request. It's the same across services only if you forward thex-request-idheader on outgoing internal calls (the header name is exported asREQUEST_ID_HEADER).trace_idties together the entire distributed lifecycle and propagates automatically via OpenTelemetry. Usetrace_idas your cross-service join key.
OpenTelemetry
Start the real OTel SDK before importing Nest application code:
import { startOpenTelemetry, useSharedLogger } from '@highwaydelite/logger';
startOpenTelemetry({ service: 'orders-api' });
async function bootstrap() {
const { NestFactory } = await import('@nestjs/core');
const { AppModule } = await import('./app.module.js');
const app = await NestFactory.create(AppModule, { bufferLogs: true });
useSharedLogger(app);
await app.listen(3000);
}
bootstrap();trace_id / span_id are injected from the active span. The logger module
creates a request-scoped non-recording span context automatically, so request
logs include trace fields even when the service has not bootstrapped a full
OTel SDK. If an inbound traceparent header is present, its trace id is
preserved; otherwise a new trace id is generated for the request.
startOpenTelemetry() initializes the SDK with Node auto-instrumentations.
Spans are not exported to stdout by default. To opt into console span output
for local debugging, pass exporter: 'console' or set
OTEL_TRACES_EXPORTER=console. To export to a collector over OTLP/HTTP, set
OTEL_EXPORTER_OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, or pass
exporter: 'otlp-http' in code.
With HTTP/DB auto-instrumentation, outgoing internal calls get their own spans
and propagate the trace context for you — so let traces own the call graph and
timing, and let logs own the rich event detail. They join on trace_id.
If you prefer automatic injection, register
@opentelemetry/instrumentation-pino and it does the same job — in that case
this package's mixin is harmless but redundant.
CloudWatch Logs Insights cookbook
Replay one request's full lifecycle across all services (select the relevant log groups, then):
fields @timestamp, service, context, operation, level, msg
| filter trace_id = "4bf92f3577b34da6a3ce929d0e0e4736"
| sort @timestamp ascEverything one service did for a request:
fields @timestamp, context, operation, msg
| filter reqId = "0b9e1c4a-7f2d-4c1e-9b3a-2d6f8e0a1c44"
| sort @timestamp ascAll warnings/errors for a service in the window:
fields @timestamp, reqId, trace_id, context, operation, msg
| filter level in ["warn", "error", "fatal"]
| sort @timestamp descSlowest requests (from the auto lifecycle line):
fields @timestamp, reqId, req.url, res.statusCode, responseTime
| filter ispresent(responseTime)
| sort responseTime desc
| limit 50Everything a specific method did:
fields @timestamp, reqId, trace_id, msg
| filter context = "OrderService" and operation = "createOrder"
| sort @timestamp ascRedaction
The baseline paths in DEFAULT_REDACT_PATHS (auth headers, cookies, api keys,
common credential body fields) are always removed and cannot be disabled.
Add service-specific paths via the redact option:
LoggerModule.forRoot({
service: 'orders-api',
redact: ['req.body.cardNumber', 'res.body.ssn'],
});Local development
pretty defaults to on outside production, giving readable colorized output
via pino-pretty. In production, leave it off: the service writes raw NDJSON to
stdout and the platform log driver (ECS/EKS/Lambda) ships it to CloudWatch.
