Skip to content

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 ​

typescript
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 ​

LevelValueUse Case
trace0Very detailed debugging
debug1Debug information
info2General information
warn3Warnings
error4Errors
fatal5Fatal errors

Usage in Controllers/Services ​

typescript
@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 ​

typescript
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 ​

typescript
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 stack

Multiple Arguments ​

typescript
// 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:

typescript
@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 ​

Configure logging declaratively using loggerOptions:

typescript
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, none
  • LOG_FORMAT - Output format: json or pretty
bash
# Example: Set log level to info and format to JSON
LOG_LEVEL=info LOG_FORMAT=json bun run start

Configuration Priority ​

Configuration is resolved in this order (first wins):

  1. loggerLayer (advanced, full control via Effect.js)
  2. loggerOptions (declarative configuration)
  3. Environment variables (LOG_LEVEL, LOG_FORMAT)
  4. NODE_ENV defaults
NODE_ENVDefault LOG_LEVELDefault LOG_FORMAT
productioninfojson
otherdebugpretty

Development vs Production ​

Logger format is automatically selected based on NODE_ENV:

typescript
// 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 ​

typescript
// 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 ​

typescript
// 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, environment

OTLP Log Export ​

Send logs to an OpenTelemetry Collector alongside console output. Uses native fetch() for Bun compatibility — no additional dependencies.

Configuration ​

typescript
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.

bash
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 bun run start

Priority 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:

  • LogLevel maps to OTLP severity: Trace→1, Debug→5, Info→9, Warning→13, Error→17, Fatal→21
  • Trace correlation: traceId and spanId are included when a span is active
  • Error info: exception.type, exception.message, exception.stacktrace attributes
  • Context fields become OTLP attributes
  • Pending logs are flushed on application shutdown

app.stop() ends with shutdownLogger(), which flushes and closes every OTLP transport in the process. shutdownLoggerLayer(layer) closes only the one a layer from makeLoggerFromOptions() was built with. That is what a failed app.start() does: it closes the logger the application built, and leaves a loggerLayer you passed in exporting for the next attempt — see When start() fails. An open OTLP transport keeps the process alive through its flush timer, so a layer you built yourself is yours to close when nothing else will.

When the Collector Rejects a Batch ​

The transport reads the status of every response, so a 503, a 404 and a success are handled differently, and a misconfigured endpoint does not swallow log lines 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.
typescript
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 ​

typescript
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) ​

json
{
  "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:

  1. Enable JSON output format
  2. Enable tracing in the application
typescript
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 ​

bash
# Set log format and level
LOG_LEVEL=info
LOG_FORMAT=json

# Run the application
bun run src/index.ts

Tracing 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:

json
{
  "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:

json
{
  "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:

typescript
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:

typescript
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:

typescript
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 ​

typescript
// 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 ​

typescript
// 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 ​

typescript
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 ​

typescript
// 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 ​

typescript
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 ​

typescript
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 });
    }
  }
}

Released under the MPL-2.0 License.