logixia — Async-First Logging for Node.js with Database Transports and Request Tracing

Sanjeev SharmaSanjeev Sharma
8 min read

Advertisement

Introduction

Why This Matters

Winston, Pino, and Bunyan write logs to files. Files are hard to query, hard to correlate across requests, and easy to lose in containerized environments. logixia takes a different approach: logs go to your database by default, where they are immediately queryable, indexable alongside your application data, and covered by your existing backup strategy.

Beyond storage, logixia solves the three hardest problems in production logging: non-blocking writes that do not degrade request latency, automatic request ID propagation via AsyncLocalStorage (no logger threading through every function call), and field redaction that prevents sensitive data from ever reaching disk.

Installation

npm install logixia
# For NestJS
npm install logixia @logixia/nestjs

Basic Setup

import { Logger } from 'logixia'
 
const logger = new Logger({
  level: 'info',
  transports: ['console'],
})
 
logger.info('Server started', { port: 3000 })
logger.warn('Rate limit approaching', { userId: 'u123', requests: 95 })
logger.error('Payment failed', { invoiceId: 'inv_456', error: err.message })

All log methods return Promises. Writes happen off the main event loop — your request handler returns immediately and the log is written in the background.

Non-Blocking Writes

The difference between synchronous and asynchronous logging matters at scale:

// Synchronous logger (Winston default) — blocks event loop per write
logger.info('Request completed')  // waits for fs.writeSync
 
// logixia — schedules the write, returns immediately
await logger.info('Request completed')
// or fire-and-forget:
logger.info('Request completed').catch(handleLogError)

Under high load, synchronous file writes add 2–5ms latency per request. logixia batches writes into a queue and flushes in configurable chunks — the event loop stays unblocked.

Database Transports

This is logixia's standout feature. Logs go directly into your existing database:

import { Logger, PostgresTransport, MySQLTransport, MongoTransport, SQLiteTransport } from 'logixia'
 
// PostgreSQL
const logger = new Logger({
  level: 'info',
  transports: [
    new PostgresTransport({
      connectionString: process.env.DATABASE_URL,
      table: 'app_logs',    // auto-created if it does not exist
      batchSize: 100,       // insert 100 log entries per query
      flushInterval: 5000,  // or flush every 5 seconds
    }),
  ],
})
 
// MySQL
new MySQLTransport({ host, user, password, database, table: 'logs' })
 
// MongoDB
new MongoTransport({ uri: process.env.MONGO_URI, collection: 'logs' })
 
// SQLite — great for local dev and edge deployments
new SQLiteTransport({ path: './logs.db' })

The auto-created table schema for PostgreSQL:

CREATE TABLE app_logs (
  id         BIGSERIAL PRIMARY KEY,
  level      VARCHAR(10)  NOT NULL,
  message    TEXT         NOT NULL,
  meta       JSONB,
  trace_id   VARCHAR(64),
  request_id VARCHAR(64),
  timestamp  TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);
 
CREATE INDEX ON app_logs (level);
CREATE INDEX ON app_logs (trace_id);
CREATE INDEX ON app_logs (timestamp);

Combine transports per environment:

const logger = new Logger({
  level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
  transports: [
    'console',
    ...(process.env.NODE_ENV === 'production'
      ? [
          new PostgresTransport({ connectionString: process.env.DATABASE_URL }),
          new FileRotationTransport({ dir: '/var/log/myapp', rotation: 'daily' }),
        ]
      : []),
  ],
})

Request Tracing with AsyncLocalStorage

The killer feature for API logs: every log line automatically includes the current request's trace ID — without passing a logger through every function call.

import { Logger, RequestContext } from 'logixia'
 
const logger = new Logger({ transports: ['console'] })
 
// Middleware sets up the AsyncLocalStorage context per request
app.use(RequestContext.middleware(logger))
 
// Every log in the request lifecycle automatically includes requestId
app.get('/orders', async (req, res) => {
  logger.info('Fetching orders')          // → includes requestId: 'req_abc123'
  const orders = await db.query('SELECT * FROM orders')
  logger.info('Orders fetched', { count: orders.length })  // → same requestId
  res.json(orders)
})
 
// Context propagates into nested service calls — no manual threading
class OrderService {
  async processOrder(orderId: string) {
    logger.info('Processing order', { orderId })  // → still has the HTTP request's requestId
  }
}

This works because logixia uses Node.js AsyncLocalStorage — the context flows through the entire async call chain including Promise.all, setTimeout callbacks, and deep async/await chains.

Field Redaction

Prevent sensitive data from reaching your logs, database, or log aggregators:

const logger = new Logger({
  transports: ['console'],
  redact: {
    fields: [
      'password',
      'token',
      'authorization',
      'creditCard',
      'user.ssn',         // dot-notation for nested fields
      'user.bankAccount',
      /apiKey/i,          // regex to match field names case-insensitively
    ],
    replacement: '[REDACTED]',
  },
})
 
logger.info('User signup', {
  email: 'user@example.com',
  password: 'supersecret123',  // → '[REDACTED]'
  user: {
    name: 'Sanjeev',
    ssn: '123-45-6789',        // → '[REDACTED]'
  },
})
// Output: { email: 'user@example.com', password: '[REDACTED]', user: { name: 'Sanjeev', ssn: '[REDACTED]' } }

Redaction happens in memory before any transport write — sensitive values never reach disk, database, or a remote log aggregator.

Log Search with SearchManager

When logs are in a database, you can actually search them — something impossible with plain log files:

import { SearchManager } from 'logixia'
 
const search = new SearchManager({ connectionString: process.env.DATABASE_URL })
 
// Full-text search across all log messages
const errors = await search.query({
  text: 'payment failed',
  level: 'error',
  from: new Date('2026-03-01'),
  to: new Date('2026-03-31'),
  limit: 50,
})
 
// Trace all logs for a single HTTP request
const requestLogs = await search.query({
  traceId: '4bf92f3577b34da6a3ce929d0e0e4736',
})
 
// Filter by metadata fields — find all warnings for a specific user
const userWarnings = await search.query({
  meta: { userId: 'u_123' },
  level: ['warn', 'error'],
})

File Rotation Transport

For apps that write to disk, logixia supports size-based and time-based rotation:

import { FileRotationTransport } from 'logixia'
 
new FileRotationTransport({
  dir: './logs',
  filename: 'app.log',
  rotation: 'daily',    // 'hourly' | 'daily' | 'weekly'
  maxFiles: 30,         // keep 30 days of logs
  maxSize: '100MB',     // also rotate if file exceeds 100MB
  compress: true,       // gzip rotated files
})
// Produces: logs/app.log (current), logs/app-2026-03-18.log.gz (yesterday)

NestJS Integration

// app.module.ts
import { Module } from '@nestjs/common'
import { LogixiaLoggerModule } from 'logixia'
 
@Module({
  imports: [
    LogixiaLoggerModule.forRoot({
      level: 'info',
      transports: [
        'console',
        new PostgresTransport({ connectionString: process.env.DATABASE_URL }),
      ],
      redact: { fields: ['password', 'token'] },
      requestContext: true,  // auto-inject request IDs via middleware
    }),
  ],
})
export class AppModule {}
// any.service.ts
import { Injectable } from '@nestjs/common'
import { InjectLogger, LogixiaLogger } from 'logixia'
 
@Injectable()
export class OrderService {
  constructor(@InjectLogger() private logger: LogixiaLogger) {}
 
  async createOrder(dto: CreateOrderDto) {
    this.logger.info('Creating order', { userId: dto.userId })
    try {
      const order = await this.db.order.create(dto)
      this.logger.info('Order created', { orderId: order.id })
      return order
    } catch (err) {
      this.logger.error('Order creation failed', { error: err.message })
      throw err
    }
  }
}

Graceful Shutdown

Buffered database writes must flush before the process exits:

process.on('SIGTERM', async () => {
  await logger.flush()  // wait for all buffered logs to write
  await logger.close()  // close transport connections
  process.exit(0)
})

Without this, the last batch of logs in the queue will be lost when the process exits.

Common Mistakes

Not calling flush() on shutdown. Database transports batch writes — without graceful shutdown, the last 5 seconds of logs may be dropped.

Logging entire request/response objects. These contain headers with credentials. Always extract only the fields you need and rely on redact as a safety net.

Using synchronous transports in a hot path. The console transport is synchronous. In production, pair it with a database transport and set level: 'warn' on console to reduce synchronous writes.

Best Practices

  • In production, use PostgreSQL or MongoDB transports so logs are queryable — plain file logging loses the ability to correlate events.
  • Set requestContext: true in NestJS or use RequestContext.middleware() in Express so every log line carries the HTTP request ID automatically.
  • Define a redact configuration at the logger level covering all sensitive field names — do not rely on developers to remember to omit them at each call site.
  • Use logger.child({ service: 'payment', environment: 'production' }) to create scoped loggers that add fixed metadata without repeating it in every log call.
  • Set adaptive.enabled: true to automatically escalate to debug-level logging when error rates spike — critical for incident investigation.

Key Takeaways

  • logixia is async-first — all transport writes are non-blocking, keeping the Node.js event loop free during high-throughput request handling.
  • Database transports (PostgreSQL, MySQL, MongoDB, SQLite) make logs queryable and part of your existing backup strategy.
  • AsyncLocalStorage request tracing automatically injects request IDs into every log line within an HTTP request's async call chain — no manual threading required.
  • Field redaction happens in memory before any write, so sensitive values (passwords, tokens, SSNs) never reach log storage.
  • SearchManager provides full-text search, trace ID filtering, and metadata filtering over logs stored in a database.
  • The NestJS LogixiaLoggerModule configures all transports, redaction, and request context in under 10 lines of module config.
  • Graceful shutdown via logger.flush() and logger.close() ensures batched database writes are not dropped when the process exits.
  • Child loggers created with logger.child({ service, module }) add fixed context to every log line without repeating it at each call site.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading