Logger API
Package: @onebun/logger
Overview
OneBun provides a structured logging system with:
- Multiple log levels
- JSON or pretty console output
- Trace context integration
- Child loggers with context inheritance
SyncLogger Interface
interface SyncLogger {
trace(message: string, ...args: unknown[]): void;
debug(message: string, ...args: unknown[]): void;
info(message: string, ...args: unknown[]): void;
warn(message: string, ...args: unknown[]): void;
error(message: string, ...args: unknown[]): void;
fatal(message: string, ...args: unknown[]): void;
child(context: Record<string, unknown>): SyncLogger;
}Log Levels
| Level | Value | Use Case |
|---|---|---|
trace | 0 | Very detailed debugging |
debug | 1 | Debug information |
info | 2 | General information |
warn | 3 | Warnings |
error | 4 | Errors |
fatal | 5 | Fatal errors |
Usage in Controllers/Services
@Controller('/users')
export class UserController extends BaseController {
@Get('/')
async findAll() {
// Logger is automatically available
this.logger.info('Finding all users');
this.logger.debug('Request received', { timestamp: Date.now() });
const users = await this.userService.findAll();
this.logger.info('Users found', { count: users.length });
return users;
}
}Logging with Context
Object Context
import { getClientAddress } from '@onebun/core';
this.logger.info('User action', {
userId: user.id,
action: 'login',
ip: getClientAddress(request),
userAgent: request.headers.get('user-agent'),
});
// Output (JSON):
// {"level":"info","message":"User action","userId":"123","action":"login","ip":"192.168.1.1",...}getClientAddress returns the address the framework itself attributes the request to — the transport peer, or the leftmost proxy-header entry when the application sets trustProxy: true. Reading x-forwarded-for directly logs whatever the caller sent: the whole client, proxy1, proxy2 chain rather than one address, null when no proxy is in front, and a forged value on an app that deliberately does not trust proxies. See Client identification and trustProxy.
Error Logging
try {
await this.riskyOperation();
} catch (error) {
// Error objects are specially handled
this.logger.error('Operation failed', error);
// With additional context
this.logger.error('Operation failed', error, {
operationId: '123',
userId: user.id,
});
}
// Output includes error name, message, and stackMultiple Arguments
// Mix of arguments
this.logger.debug(
'Processing request',
requestData, // Object merged into context
{ step: 1 }, // Another object merged
'additional info', // String goes to additionalData
42, // Number goes to additionalData
);Child Loggers
Create child loggers with inherited context:
@Service()
export class OrderService extends BaseService {
async processOrder(orderId: string): Promise<void> {
// Create child logger with order context
const orderLogger = this.logger.child({
orderId,
operation: 'processOrder',
});
orderLogger.info('Starting order processing');
// Logs: {"orderId":"123","operation":"processOrder","message":"Starting order processing",...}
await this.validateOrder(orderId, orderLogger);
await this.processPayment(orderId, orderLogger);
orderLogger.info('Order processing completed');
}
private async validateOrder(orderId: string, logger: SyncLogger): Promise<void> {
logger.debug('Validating order');
// Context (orderId, operation) is inherited
}
}Logger Configuration
Using loggerOptions (Recommended)
Configure logging declaratively using loggerOptions:
const app = new OneBunApplication(AppModule, {
loggerOptions: {
minLevel: 'info', // 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'none'
format: 'json', // 'json' | 'pretty'
defaultContext: {
service: 'user-service',
version: '1.0.0',
},
},
});Environment Variables
Logger configuration can be controlled via environment variables:
LOG_LEVEL- Set minimum log level:trace,debug,info,warn/warning,error,fatal,noneLOG_FORMAT- Output format:jsonorpretty
# Example: Set log level to info and format to JSON
LOG_LEVEL=info LOG_FORMAT=json bun run startConfiguration Priority
Configuration is resolved in this order (first wins):
loggerLayer(advanced, full control via Effect.js)loggerOptions(declarative configuration)- Environment variables (
LOG_LEVEL,LOG_FORMAT) NODE_ENVdefaults
| NODE_ENV | Default LOG_LEVEL | Default LOG_FORMAT |
|---|---|---|
| production | info | json |
| other | debug | pretty |
Development vs Production
Logger format is automatically selected based on NODE_ENV:
// NODE_ENV !== 'production' → Pretty console output
// NODE_ENV === 'production' → JSON output
// Manual override using loggerOptions
const app = new OneBunApplication(AppModule, {
loggerOptions: {
minLevel: 'debug',
format: 'pretty',
},
});
// Or using loggerLayer (advanced)
import { makeDevLogger, makeProdLogger } from '@onebun/logger';
const app = new OneBunApplication(AppModule, {
loggerLayer: makeDevLogger({
minLevel: LogLevel.Debug,
}),
});Log Levels
// Using loggerOptions (recommended)
const app = new OneBunApplication(AppModule, {
loggerOptions: {
minLevel: 'info', // Ignore trace and debug
},
});
// Or using loggerLayer with LogLevel enum
import { LogLevel, makeLogger } from '@onebun/logger';
const app = new OneBunApplication(AppModule, {
loggerLayer: makeLogger({
minLevel: LogLevel.Info,
}),
});Custom Context
// Using loggerOptions
const app = new OneBunApplication(AppModule, {
loggerOptions: {
defaultContext: {
serviceName: 'user-service',
version: '1.0.0',
environment: process.env.NODE_ENV,
},
},
});
// Or using makeLogger
import { makeLogger } from '@onebun/logger';
const app = new OneBunApplication(AppModule, {
loggerLayer: makeLogger({
defaultContext: {
serviceName: 'user-service',
version: '1.0.0',
environment: process.env.NODE_ENV,
},
}),
});
// All logs will include serviceName, version, environmentOTLP Log Export
Send logs to an OpenTelemetry Collector alongside console output. Uses native fetch() for Bun compatibility — no additional dependencies.
Configuration
const app = new OneBunApplication(AppModule, {
loggerOptions: {
format: 'json',
minLevel: 'info',
defaultContext: { service: 'my-service' },
// Enable OTLP log export
otlpEndpoint: 'http://localhost:4318',
otlpHeaders: { 'Authorization': 'Bearer token' },
otlpBatchSize: 100, // logs per batch (default: 100)
otlpBatchTimeout: 5000, // max wait before flush (default: 5000ms)
},
tracing: {
serviceName: 'my-service', // auto-populated as OTLP resource attribute
serviceVersion: '1.0.0',
},
});When otlpEndpoint is set, logs are sent to both console and OTLP collector. The service.name and service.version resource attributes are populated from tracing config.
Environment Variable
OTLP export is also enabled when OTEL_EXPORTER_OTLP_LOGS_ENDPOINT or OTEL_EXPORTER_OTLP_ENDPOINT is set — with no loggerOptions at all, which is the point: the same image goes to dev, staging and prod and observability is switched on by injection rather than by a code change.
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 bun run startPriority is otlpEndpoint > OTEL_EXPORTER_OTLP_LOGS_ENDPOINT > OTEL_EXPORTER_OTLP_ENDPOINT.
Records exported this way carry the same resource attributes as the explicit path: service.name from tracing.serviceName, then OTEL_SERVICE_NAME, then onebun-service; service.version from tracing.serviceVersion, then 1.0.0. A record that arrives with an empty resource cannot be attributed to a service, which is most of what a log backend is for.
OTLP Format
Log entries are sent as OTLP JSON to {endpoint}/v1/logs:
LogLevelmaps to OTLP severity: Trace→1, Debug→5, Info→9, Warning→13, Error→17, Fatal→21- Trace correlation:
traceIdandspanIdare included when a span is active - Error info:
exception.type,exception.message,exception.stacktraceattributes - Context fields become OTLP attributes
- Pending logs are flushed on application shutdown
When the Collector Rejects a Batch
The response is inspected. A 503, a 404 and a success used to be indistinguishable — nothing looked at the status — so a misconfigured endpoint swallowed every log line in silence.
- Held and retried on a transport failure (connection refused, DNS, TLS, timeout) and on 408/429/500/502/503/504. The records go back to the head of the buffer, in order, and the next scheduled flush sends them again.
- Discarded on any other status. A 400 means the collector refused the payload and will refuse it identically; holding it would fill the buffer with records that can never leave.
- Bounded by
otlpMaxBufferedRecords(default 1000). A collector that stays down would otherwise grow the buffer until the process dies, and a logger that kills the application to preserve its own backlog has its priorities backwards. Oldest go first, and the drop is reported. - Reported through
otlpOnExportFailure, which defaults to a line on stderr. It cannot go through the logger — that feeds the transport which just failed, and the loop is tightest exactly when the collector is down.
const app = new OneBunApplication(AppModule, {
loggerOptions: {
otlpEndpoint: 'http://localhost:4318',
otlpMaxBufferedRecords: 5000,
otlpOnExportFailure: (error, recordCount) => {
process.stderr.write(`lost ${recordCount} log records: ${error.message}\n`);
},
},
});Getting Logger from Application
const app = new OneBunApplication(AppModule, { envSchema });
await app.start();
// Get root logger
const logger = app.getLogger();
logger.info('Application started');
// Get logger with context
const bootstrapLogger = app.getLogger({ className: 'Bootstrap' });
bootstrapLogger.info('Bootstrapping complete');Output Formats
Pretty Format (Development)
[2024-01-15T10:30:45.123Z] INFO [UserController] User created
userId: "abc-123"
email: "user@example.com"JSON Format (Production)
{
"level": "info",
"message": "User created",
"timestamp": "2024-01-15T10:30:45.123Z",
"context": {
"className": "UserController",
"userId": "abc-123",
"email": "user@example.com"
}
}Enabling JSON Logging with Trace Context
To get structured JSON logs with trace context for production observability, you need to:
- Enable JSON output format
- Enable tracing in the application
Option 1: Via Application Options (Recommended)
import { OneBunApplication } from '@onebun/core';
import { AppModule } from './app.module';
const app = new OneBunApplication(AppModule, {
port: 3000,
// Enable JSON logging
loggerOptions: {
minLevel: 'info',
format: 'json',
},
// Enable tracing — trace context will appear in all logs
tracing: {
enabled: true,
serviceName: 'user-service',
traceHttpRequests: true,
},
});
await app.start();Option 2: Via Environment Variables
# Set log format and level
LOG_LEVEL=info
LOG_FORMAT=json
# Run the application
bun run src/index.tsTracing is enabled by default when tracing.enabled is set in app options or when the application detects @onebun/trace is installed.
Combined JSON + Trace Output
With both JSON logging and tracing enabled, a log entry automatically includes the trace context:
{
"timestamp": "2024-01-15T10:30:45.123Z",
"level": "info",
"message": "User created",
"trace": {
"traceId": "abc123def456789012345678",
"spanId": "span123456789",
"parentSpanId": "parent456789"
},
"context": {
"className": "UserController",
"userId": "usr_123",
"email": "user@example.com"
}
}The trace field is automatically injected by the logger whenever a span is active. No code changes needed in your controllers or services — just use this.logger as usual.
That covers an HTTP request, a @Traced or @Span method, and — because each of those boundaries opens a span of its own — a queue handler, a @Cron/@Interval/@Timeout job, and a WebSocket connection or message handler.
The trace key is absent, not empty, where no span is open: bootstrap and shutdown code, and any handler running with tracing.enabled: false or with its own kind switched off — tracing.traceQueueMessages, tracing.traceScheduledJobs, tracing.traceWebSocketEvents. A log line never carries a trace id that names nothing.
Trace Context Integration
When tracing is enabled, logs automatically include trace context:
{
"level": "info",
"message": "Processing request",
"timestamp": "2024-01-15T10:30:45.123Z",
"trace": {
"traceId": "abc123def456",
"spanId": "span123",
"parentSpanId": "parent456"
}
}Effect.js Logger (Advanced)
For Effect.js integration, use async Logger interface:
import { LoggerService, Logger } from '@onebun/logger';
import { Effect, pipe } from 'effect';
// Get logger in Effect context
const program = pipe(
LoggerService,
Effect.flatMap((logger: Logger) =>
logger.info('Message from Effect')
),
);
// Run with logger layer
Effect.runPromise(
Effect.provide(program, makeLogger())
);Testing
Mock Logger
makeMockLoggerLayer() takes no arguments and returns a silent layer — use it to keep test output clean when you do not care what was logged:
import { makeMockLoggerLayer } from '@onebun/core/testing';
const app = new OneBunApplication(AppModule, {
loggerLayer: makeMockLoggerLayer(),
});To assert on what was logged, provide a layer that records. Logger is a plain object of methods returning Effect, so a capturing implementation is a few lines:
import { Effect, Layer } from 'effect';
import { LoggerService, type Logger } from '@onebun/logger';
const logs: Array<{ level: string; message: string }> = [];
const record = (level: string) => (message: string) =>
Effect.sync(() => {
logs.push({ level, message });
});
const capturingLogger: Logger = {
trace: record('trace'),
debug: record('debug'),
info: record('info'),
warn: record('warn'),
error: record('error'),
fatal: record('fatal'),
child: () => capturingLogger,
};
const app = new OneBunApplication(AppModule, {
loggerLayer: Layer.succeed(LoggerService, capturingLogger),
});
// ... exercise the application ...
expect(logs).toContainEqual({ level: 'info', message: 'User created' });WARNING
makeMockLoggerLayer() accepts no callback. Passing one is a compile error, and the mock it builds discards every entry — a test that expected to collect entries through it would assert against an empty array forever.
Best Practices
1. Use Appropriate Log Levels
// trace: Very detailed, usually disabled
this.logger.trace('Entering function', { args });
// debug: Useful for debugging
this.logger.debug('Cache lookup', { key, hit: !!value });
// info: Normal operations
this.logger.info('User logged in', { userId });
// warn: Potential issues
this.logger.warn('Rate limit approaching', { current, limit });
// error: Errors that need attention
this.logger.error('Database connection failed', error);
// fatal: Critical errors
this.logger.fatal('Application cannot start', error);2. Include Relevant Context
// Good: Includes useful context
this.logger.info('Order placed', {
orderId: order.id,
customerId: order.customerId,
total: order.total,
itemCount: order.items.length,
});
// Bad: Missing context
this.logger.info('Order placed');3. Use Child Loggers for Operations
async processRequest(requestId: string, userId: string) {
const logger = this.logger.child({ requestId, userId });
logger.info('Request started');
// All subsequent logs include requestId and userId
try {
await this.step1(logger);
await this.step2(logger);
logger.info('Request completed');
} catch (error) {
logger.error('Request failed', error);
throw error;
}
}4. Don't Log Sensitive Data
// Bad: Logs password
this.logger.info('User login', { email, password });
// Good: Omit sensitive fields
this.logger.info('User login', { email });
// Or mask them
this.logger.info('User login', { email, password: '***' });5. Log at Entry/Exit Points
async processOrder(orderId: string): Promise<Order> {
this.logger.info('Processing order started', { orderId });
try {
const result = await this.doProcess(orderId);
this.logger.info('Processing order completed', {
orderId,
duration: Date.now() - startTime,
});
return result;
} catch (error) {
this.logger.error('Processing order failed', {
orderId,
error,
});
throw error;
}
}Complete Example
import { Service, BaseService } from '@onebun/core';
import { Span } from '@onebun/trace';
@Service()
export class PaymentService extends BaseService {
@Span('process-payment')
async processPayment(orderId: string, amount: number): Promise<PaymentResult> {
const logger = this.logger.child({
orderId,
amount,
operation: 'processPayment',
});
logger.info('Payment processing started');
try {
// Validate
logger.debug('Validating payment');
await this.validatePayment(amount);
// Process
logger.debug('Charging payment gateway');
const result = await this.gateway.charge({
orderId,
amount,
currency: 'USD',
});
if (result.success) {
logger.info('Payment successful', {
transactionId: result.transactionId,
});
} else {
logger.warn('Payment declined', {
reason: result.declineReason,
});
}
return result;
} catch (error) {
logger.error('Payment processing failed', error);
throw error;
}
}
private async validatePayment(amount: number): Promise<void> {
if (amount <= 0) {
this.logger.warn('Invalid payment amount', { amount });
throw new Error('Amount must be positive');
}
if (amount > 10000) {
this.logger.warn('Large payment amount, additional verification required', { amount });
}
}
}