Logging in Bun often means choosing between "fast but dumb" or "smart but blocking". I built NowaraJS Logger because I wanted both: a type-safe, sink-based system that never blocks your main thread.
The goal is simple: Stop your logs from slowing down your app.
Most loggers either block on every write or lose type safety when you need structured logging. This package runs everything in a worker thread, batches automatically, and still gives you full TypeScript inference on what you log.
any everywhere.bun add @nowarajs/logger
You'll also need:
bun add @nowarajs/error @nowarajs/typed-event-emitter
Create a logger, attach a sink, and start logging:
import { Logger } from '@nowarajs/logger';
import { ConsoleLoggerSink } from '@nowarajs/logger/sinks';
// Create a logger and register a console sink
const logger = new Logger().registerSink('console', ConsoleLoggerSink);
// Log messages (always pass an object)
logger.info({ message: 'Application started' });
logger.warn({ message: 'This is a warning' });
logger.error({ message: 'An error occurred', code: 500 });
logger.debug({ action: 'debug_info', data: { foo: 'bar' } });
logger.log({ event: 'generic_log' });
// Close the logger when done
await logger.close();
Need logs going to different places? Register as many sinks as you want:
import { Logger } from '@nowarajs/logger';
import { ConsoleLoggerSink, FileLoggerSink } from '@nowarajs/logger/sinks';
// Register multiple sinks
const logger = new Logger()
.registerSink('console', ConsoleLoggerSink)
.registerSink('file', FileLoggerSink, './app.log');
// Log to all sinks
logger.info({ message: 'This goes to console and file' });
// Log to specific sinks only
logger.error({ message: 'Only in file' }, ['file']);
logger.warn({ message: 'Only in console' }, ['console']);
await logger.close();
Have a weird logging requirement? Write your own sink:
import type { LoggerSink, LogLevels } from '@nowarajs/logger/types';
// Create a custom sink
class DatabaseSink implements LoggerSink {
public async log(level: LogLevels, timestamp: number, object: unknown): Promise<void> {
// Your custom logging logic
await saveToDatabase({ level, timestamp, object });
}
}
const logger = new Logger().registerSink('database', DatabaseSink);
logger.info({ event: 'user_created', userId: 42 });
await logger.close();
This is where it gets interesting. When you define typed sinks, TypeScript knows exactly what shape your logs need. No more guessing, no more runtime surprises.
import type { LoggerSink, LogLevels } from '@nowarajs/logger/types';
// Define your log object type
interface UserLog {
userId: number;
action: string;
timestamp?: Date;
}
// Create a typed sink
class UserLogSink implements LoggerSink<UserLog> {
public async log(level: LogLevels, timestamp: number, object: UserLog): Promise<void> {
console.log(`User ${object.userId} performed: ${object.action}`);
}
}
const logger = new Logger().registerSink('userLog', UserLogSink);
// ✅ TypeScript requires the correct shape
logger.info({
userId: 123,
action: 'login'
});
// ❌ TypeScript error: Missing required property 'action'
logger.info({
userId: 123
// Error: Property 'action' is missing
});
When logging to multiple sinks at once, TypeScript creates an intersection of all types. You need to satisfy all of them:
interface UserLog {
userId: number;
action: string;
}
interface ApiLog {
endpoint: string;
method: string;
statusCode: number;
}
class UserLogSink implements LoggerSink<UserLog> {
public async log(level: LogLevels, timestamp: number, object: UserLog): Promise<void> {
await saveUser(object);
}
}
class ApiLogSink implements LoggerSink<ApiLog> {
public async log(level: LogLevels, timestamp: number, object: ApiLog): Promise<void> {
await saveApi(object);
}
}
const logger = new Logger().registerSink('user', UserLogSink).registerSink('api', ApiLogSink);
// ✅ When using both sinks, you need BOTH types combined
logger.info(
{
userId: 123,
action: 'api_call',
endpoint: '/users',
method: 'POST',
statusCode: 201
},
['user', 'api']
); // Logs to both sinks
// ✅ When using only one sink, only that type is required
logger.warn(
{
userId: 456,
action: 'failed_attempt'
},
['user']
); // Only UserLog type required
// ❌ TypeScript error: Missing api properties
logger.error(
{
userId: 789,
action: 'error'
},
['user', 'api']
);
When you mix typed sinks with untyped ones (like ConsoleLoggerSink which accepts unknown), things stay flexible:
interface DatabaseLog {
query: string;
duration: number;
}
class DatabaseLogSink implements LoggerSink<DatabaseLog> {
public async log(level: LogLevels, timestamp: number, object: DatabaseLog): Promise<void> {
await logToDatabase(object);
}
}
const logger = new Logger()
.registerSink('database', DatabaseLogSink)
.registerSink('console', ConsoleLoggerSink); // Accepts unknown
// ✅ This works - intersection with unknown allows extra properties
logger.info(
{
query: 'SELECT * FROM users',
duration: 123,
customData: 'anything goes'
},
['database', 'console']
);
Things break. When they do, you'll want to know:
const logger = new Logger().registerSink('console', ConsoleLoggerSink);
// Listen for errors
logger.addListener('sinkError', (error) => {
console.error('Logger error:', error.message);
});
logger.addListener('registerSinkError', (error) => {
console.error('Failed to register sink:', error.message);
});
logger.info({ message: 'Safe to log' });
await logger.close();
When you need to make sure everything is written before shutting down:
const logger = new Logger().registerSink('console', ConsoleLoggerSink);
logger.info({ message: 'First message' });
logger.info({ message: 'Second message' });
// Wait for all pending logs to be processed
await logger.flush();
// Close the logger and release resources (internally calls flush)
await logger.close();
Fine-tune the batching and queue behavior:
const logger = new Logger({
maxPendingLogs: 5000, // Max queued logs (default: 10,000)
batchSize: 50, // Logs per batch (default: 50)
batchTimeout: 100, // Ms before flushing batch (default: 0.1)
maxMessagesInFlight: 100, // Max batches being processed (default: 100)
autoEnd: true, // Auto-close on process exit (default: true)
flushOnBeforeExit: true // Flush before exit (default: true)
});
Full docs: nowarajs.github.io/logger
MIT – Use it however you want.