contextual 2.2.0
contextual: ^2.2.0 copied to clipboard
A structured logging library for Dart with support for multiple output channels, customizable formatting, context management, and middleware processing.
Contextual #
A structured logging library for Dart

Features #
- 🪵 Multiple Log Levels - From debug to emergency following RFC 5424
- 🎨 Flexible Formatting - JSON, logfmt-style plain text, and colored output
- 📊 Rich Context Support - Add structured data to your logs
- 🔄 Middleware - Transform and filter log messages
- 📤 Multiple Outputs - Console, files, webhooks, and more
- 🎯 Type-specific Formatting - Custom formatting for your data types
- ⚡ Async & Batching - Efficient log processing
- 🔀 Per-Channel Formatters - Customize formatting for each channel
Installation #
Add contextual to your pubspec.yaml:
dependencies:
contextual: ^1.0.0
Quick Start #
import 'package:contextual/contextual.dart';
void main() {
// Create a logger with a minimum level
final logger = Logger(level: Level.info)
..withContext({'app': 'MyApp'});
// Basic logging
logger.info('Application started');
logger.debug('Configuration loaded');
logger.error('Something went wrong');
// Logging with context
logger.warning(
'Database connection failed',
{
'host': 'localhost',
'port': 5432,
'attempts': 3
}
);
}
See the [example](example) folder for detailed examples, including [named_loggers_example.dart](example/named_loggers_example.dart) and [named_loggers_basic.dart](example/named_loggers_basic.dart) for named logger usage.
## Named Loggers
Contextual supports hierarchical named loggers similar to the standard `logging` package:
```dart
// Get named loggers
final root = Logger.root; // Root logger
final app = Logger(name: 'app', level: Level.info); // Child of root with level
final db = Logger(name: 'app.database', level: Level.debug); // Child of app with level
// Child loggers inherit configuration from parents unless overridden
db.info('Database connected'); // Uses inherited channels, overridden level
// Add channels at different levels
root.addChannel('console', ConsoleLogDriver());
app.addChannel('file', DailyFileLogDriver('app.log'));
// db will log to both console (inherited) and file (from app)
db.error('Query failed');
Named loggers automatically create parent loggers and inherit:
- Log levels (children can override by setting level in constructor or calling setLevel)
- Channels (children inherit and can add their own)
- Formatters and type formatters
- Context and middleware
The logger's name is automatically included in the log context as logger: 'name' (or logger: 'root' for the root logger).
This allows for fine-grained control over logging in different parts of your application.
Load configuration from JSON:
final config = LogConfig(
level: 'debug',
environment: 'development',
channels: const [
ConsoleChannel(ConsoleOptions(), name: 'console'),
DailyFileChannel(DailyFileOptions(path: 'logs/app', retentionDays: 7), name: 'file'),
WebhookChannel(WebhookOptions(url: Uri.parse('https://hooks.slack.com/...')), name: 'slack'),
],
);
final logger = await Logger.create(config: config);
If no configuration is provided, a default configuration will be used. The default configuration logs using the console driver and formats logs using the plain (logfmt-style) formatter. To disable the the default console logger, at initialization, set the defaultChannelEnabled value to false in the constructor of your Logger instance.
File channels default to PlainTextLogFormatter unless a formatter is provided.
final logger = await Logger.create(config: const LogConfig(
channels: [ConsoleChannel(ConsoleOptions(), name: 'console')],
),
);
Logging Patterns #
Contextual supports two patterns for handling log output:
-
Direct driver dispatch (Default) Simple and predictable: logs are sent directly to drivers.
You can opt into centralized batching for driver dispatch with one line using the provided extension:
final logger = Logger() ..addChannel('console', ConsoleLogDriver()); await logger.batched(LogSinkConfig( batchSize: 50, // Number of logs to batch before flushing flushInterval: Duration(seconds: 5), // Time interval for automatic flushing )); // ... your logging code ... // Ensure all logs are delivered before shutting down await logger.shutdown();To disable batching again:
await logger.unbatched(); -
Listener Pattern
For simpler use cases, you can use a listener similar to the logging package:
final logger = Logger(); // Set up a simple listener logger.setListener((logEntry) { print('[${logEntry.record.time}] ${logEntry.record.level}: $logEntry.message'); }); // All logs now go to the listener logger.info('This goes to the listener');The listener pattern is simpler but doesn't support:
- Multiple output destinations
- Channel-specific middleware
- Asynchronous batching
Choose the pattern that best fits your needs:
- Use the sink pattern for production systems needing multiple outputs.
Runtime Channels #
A channel is represented by a Channel<T extends LogDriver> which bundles the channel name, driver, optional formatter, and optional middlewares.
final logger = await Logger.create()
..addChannel('console', ConsoleLogDriver(), formatter: PrettyLogFormatter());
final ch = logger.getChannel('console');
if (ch != null) {
final updated = ch.copyWith(formatter: JsonLogFormatter());
logger.addChannel(
updated.name,
updated.driver,
formatter: updated.formatter,
middlewares: updated.middlewares,
);
}
// Target drivers by type
logger.forDriver<ConsoleLogDriver>().info('Only console drivers');
- Use the listener pattern for simple logging.
Log Levels #
Supports standard RFC 5424 severity levels:
logger.emergency('System is unusable');
logger.alert('Action must be taken immediately');
logger.critical('Critical conditions');
logger.error('Error conditions');
logger.warning('Warning conditions');
logger.notice('Normal but significant condition');
logger.info('Informational messages');
logger.debug('Debug-level messages');
Formatters #
Choose from built-in formatters or create your own. You can set formatters per channel to customize the output for each logging destination.
Setting Formatters Per Channel #
final logger = Logger()
..environment('development')
..withContext({'app': 'MyApp'})
// Console channel with PrettyLogFormatter
..addChannel(
'console',
ConsoleLogDriver(),
formatter: PrettyLogFormatter(),
)
// File channel with JsonLogFormatter
..addChannel(
'file',
DailyFileLogDriver('logs/app.log', retentionDays: 7),
formatter: JsonLogFormatter(),
);
// Logs sent to 'console' will be pretty-printed
// Logs sent to 'file' will be in JSON format
Output Destinations #
Configure multiple output destinations and target them fluently:
final logger = Logger()
..addChannel(
'console',
ConsoleLogDriver(),
formatter: PrettyLogFormatter(),
)
..addChannel(
'file',
DailyFileLogDriver('logs/app.log', retentionDays: 7),
formatter: JsonLogFormatter(),
);
// Target a single channel (fluent):
logger['console'].info('Only console');
// Or with a method:
logger.channel('console').warning('Only console');
// Or multiple channels:
logger.channels(['console', 'file']).error('Both console and file');
If no formatter is specified for a channel, the logger's default formatter is used.
Using Built-in Formatters #
// Set the default formatter for the logger
logger.formatter(PlainTextLogFormatter());
// Choose from the built-in formatters
logger.formatter(PrettyLogFormatter()); // Colorful, human-readable
logger.formatter(JsonLogFormatter()); // JSON format
logger.formatter(RawLogFormatter()); // No formatting
// Set formatter per channel in configuration
final config = LogConfig(
channels: const [
ConsoleChannel(ConsoleOptions(), name: 'console'),
DailyFileChannel(DailyFileOptions(path: 'logs/app', retentionDays: 7), name: 'file'),
],
);
// Apply custom formatter per channel directly when adding channels programmatically,
// or use a builder API if you add one later.
Custom Formatters #
Create custom formatters to have full control over log output:
class MyCustomFormatter extends LogMessageFormatter {
@override
String format(Level level, String message, Context context) {
// Your custom formatting logic
return '[Custom] $level: $message';
}
}
// Register the custom formatter
logger.registerFormatter('custom', () => MyCustomFormatter());
// Assign the custom formatter to a channel
logger.addChannel(
'myChannel',
ConsoleLogDriver(),
formatter: MyCustomFormatter(),
);
Type-specific Formatting #
Create custom formatters for your data types:
class User {
final String name;
final String email;
User(this.name, this.email);
}
class UserFormatter extends LogTypeFormatter<User> {
@override
String format(Level level, User user, Context context) {
return '{"name": "${user.name}", "email": "${user.email}"}';
}
}
// Register the formatter
logger.addTypeFormatter(UserFormatter());
// Now User objects will be formatted automatically
final user = User('John Doe', 'john@example.com');
logger.info(user);
Output Destinations #
Configure multiple output destinations:
logger
// Console output with pretty formatting
.addChannel(
'console',
ConsoleLogDriver(),
formatter: PrettyLogFormatter(),
)
// Daily rotating file with JSON formatting
.addChannel(
'file',
DailyFileLogDriver('logs/app.log', retentionDays: 7),
formatter: JsonLogFormatter(),
)
// Webhook (e.g., Slack) with plain text formatting
.addChannel(
'slack',
WebhookLogDriver(Uri.parse('https://hooks.slack.com/...')),
formatter: PlainTextLogFormatter(),
);
// Log to specific destinations
logger['console'].info('Console only');
logger['file'].info('File only');
logger.forDriver<ConsoleLogDriver>().info('Console by type');
Context #
Add structured data to your logs:
// Global context for all logs
logger.withContext({
'environment': 'production',
'version': '1.0.0'
});
// Per-log context
logger.info(
'User logged in',
{
'userId': '123',
'ipAddress': '192.168.1.1'
}
);
// Optional stack trace
logger.error('Failed to process request', {'requestId': 'abc'}, StackTrace.current);
Per-log context accepts any object. Use a Map for structured fields; other
objects are stored under the context key.
The default plain formatter emits logfmt-style key/value pairs. Nested maps
are flattened using dotted keys (e.g. user.id=123), and a provided
StackTrace is emitted as stackTrace=....
Middleware #
Transform or filter logs:
// Add sensitive data filter
logger.addLogMiddleware(SensitiveDataMiddleware());
// Add request ID to all logs
logger.addMiddleware(() => {
'requestId': generateRequestId()
});
// Channel-specific middleware
// Driver-type specific middleware (applies to all ConsoleLogDrivers)
logger.addDriverMiddleware<ConsoleLogDriver>(ErrorOnlyMiddleware());
Advanced Usage #
Batch Processing #
Configure batching behavior:
final logger = Logger(
sinkConfig: LogSinkConfig(
batchSize: 50, // Flush after 50 logs
flushInterval: Duration(seconds: 5), // Or every 5 seconds
maxRetries: 3, // Retry failed operations
autoFlush: true // Enable automatic flushing
)
);
Custom Drivers #
Implement your own log destinations:
class CustomLogDriver implements LogDriver {
@override
Future<void> log(String formattedMessage) async {
// Your custom logging logic
}
}
Channels #
Channels are named logging destinations that can be configured independently. Each channel represents a different way to handle log messages:
// Configure multiple channels with per-channel formatters
final logger = await Logger.create(
config: LogConfig(
channels: [
ConsoleChannel(ConsoleOptions(), name: 'console'),
DailyFileChannel(
DailyFileOptions(path: 'logs/app.log', retentionDays: 7),
name: 'daily',
),
WebhookChannel(
WebhookOptions(url: Uri.parse('https://hooks.slack.com/...')),
name: 'slack',
),
],
),
);
// Log to specific channels
logger['console'].info('Regular log message');
logger['daily'].info('Regular log message');
logger['slack'].critical('Critical system failure!');
// Default behavior logs to all channels for the current environment
logger.error('This goes to all active channels'
);
Environment-based Channel Selection #
Channels can be configured to be active only in specific environments:
final logger = Logger(environment: 'production')
..addChannel(
'console',
ConsoleLogDriver(),
formatter: PrettyLogFormatter(),
) // No env specified, always active
..addChannel(
'daily',
DailyFileLogDriver('logs/app.log'),
formatter: JsonLogFormatter(),
) // Production only
..addChannel(
'debug',
ConsoleLogDriver(),
formatter: PrettyLogFormatter(),
); // Development only
// In production: logs to 'console' and 'daily'
// In development: logs to 'console' and 'debug'
Driver Configuration Options #
Below are the configuration options for each available driver:
-
Console Driver
- Driver Name:
console - Configuration: No additional configuration required.
- Driver Name:
-
Daily File Driver
- Driver Name:
daily - Configuration Options:
path: The file path where logs will be stored. Default islogs/default.log.days: Number of days to retain log files. Default is14.
- Driver Name:
-
Webhook Driver
- Driver Name:
webhook - Configuration Options:
url: The webhook URL to send logs to.
- Driver Name:
-
Sampling Driver
- Driver Name:
sampling - Configuration Options:
sample_rates: A map of log levels to sampling rates.wrapped_driver: Configuration for the driver that will be wrapped by the sampling driver. Must include adriverkey.
- Driver Name:
-
Stack Driver
- Driver Name:
stack - Configuration Options:
channels: A list of channel names to forward logs to.ignore_exceptions: Boolean to ignore exceptions during logging. Default isfalse.
- Driver Name:
Example Configuration #
final config = LogConfig(
'channels': {
'console': {
'driver': 'console',
},
'daily': {
'driver': 'daily',
'config': {
'path': 'logs/app.log',
'days': 30,
},
},
},
);
final logger = await Logger.create(config: config);
'webhook': {
'driver': 'webhook',
'config': {
'url': 'https://hooks.slack.com/...',
},
},
'sampling': {
'driver': 'sampling',
'config': {
'sample_rates': {
'info': 0.1,
'error': 1.0,
},
'wrapped_driver': {
'driver': 'console',
},
},
},
'stack': {
'driver': 'stack',
'config': {
'channels': ['console', 'daily'],
'ignore_exceptions': true,
},
},
},
});
Stack Channels #
Stack channels allow you to create a single channel that forwards logs to multiple other channels. This is useful when you want to send the same logs to multiple destinations with different formatting and filtering:
final config = LogConfig(
'channels': {
// Individual channels with their own formatters
'file': {
'driver': 'daily',
'config': {
'path': 'logs/app.log',
}
'formatter': 'json'
},
'slack': {
'driver': 'webhook',
'config': {
'url': 'https://hooks.slack.com/services/...',
}
},
},
);
final logger = await Logger.create(config: config);
// Stack channel that combines both
'production': {
'driver': 'stack',
'config': {
'channels': ['file', 'slack'], // Will forward to both channels
'ignore_exceptions': true
}
}
}
});
// Now you can log to both channels with one call
logger['production'].error('Critical failure');
Stack channels are particularly useful for:
- Sending critical logs to multiple destinations
- Applying different formatting per destination
- Creating backup logging channels
- Setting up monitoring and notification systems
Each channel in a stack maintains its own middleware chain and formatter, allowing for independent processing of logs for each destination.
Understanding Middleware #
Contextual uses a two-stage middleware system to provide flexible log processing:
Context Middleware #
Context middleware runs first and can add or modify the context data before any formatting happens:
// Add request ID and timestamp to all logs
logger.addMiddleware(() => {
'requestId': generateRequestId(),
'timestamp': DateTime.now().toIso8601String()
});
// Add dynamic user info when available
logger.addMiddleware(() {
if (currentUser != null) {
return {'userId': currentUser.id};
}
return {};
});
// Now these fields are automatically added
logger.info('User action'); // Includes requestId, timestamp, and userId
Driver and Channel Middleware #
Driver middleware processes log entries before they reach specific drivers. Channel middleware works similarly but is associated with channels.
// Example of a middleware that filters sensitive data
class SensitiveDataMiddleware implements DriverMiddleware {
@override
FutureOr<DriverMiddlewareResult> handle(
LogEntry entry,
) async {
var message = entry.message;
message = message.replaceAll(RegExp(r'password=[\w\d]+'), 'password=***');
return DriverMiddlewareResult.modify(entry.copyWith(message: message));
}
}
// Example of a middleware that only allows error-level logs
class ErrorOnlyMiddleware implements DriverMiddleware {
@override
FutureOr<DriverMiddlewareResult> handle(
LogEntry entry,
) async {
final errorLevels = ['emergency', 'alert', 'critical', 'error'];
if (!errorLevels.contains(entry.record.level.name.toLowerCase())) {
return DriverMiddlewareResult.stop();
}
return DriverMiddlewareResult.proceed();
}
}
// Add middlewares
logger
// Global middleware applied to all drivers
.addLogMiddleware(SensitiveDataMiddleware())
// Channel-specific middleware only applied to the 'slack' channel
.addDriverMiddleware<WebhookLogDriver>(ErrorOnlyMiddleware());
// Middleware execution flow:
// 1. Context middleware runs first
// 2. Log is formatted
// 3. Global driver middlewares process the log
// 4. Channel-specific middlewares process the log
// 5. Log is sent to the driver
Asynchronous Logging and Shutdown #
The logger uses asynchronous processing and batching to improve performance. This means you must properly shut down the logger to ensure all logs are delivered:
void main() async {
final logger = Logger(
sinkConfig: LogSinkConfig(
batchSize: 50, // Buffer up to 50 logs
flushInterval: Duration(seconds: 5), // Or flush every 5 seconds
)
);
try {
// Your application code
logger.info('Application starting');
await runApp();
logger.info('Application shutting down');
} finally {
// Ensure all logs are delivered before exiting
await logger.shutdown();
}
}
// In a web application
class MyApp {
final Logger logger;
Future<void> stop() async {
logger.notice('Stopping application');
await stopServices();
await logger.shutdown();
}
}
The shutdown() method:
- Flushes any buffered log messages
- Waits for all async log operations to complete
- Ensures webhook requests are sent
- Closes file handles and other resources
Always call shutdown() before your application exits to prevent log message loss.
Conclusion #
Contextual provides a flexible and powerful logging solution for Dart applications. By allowing per-channel formatters, you can customize the output of each logging destination to suit your needs. Whether you're developing a simple application or a complex system with multiple output channels, Contextual offers the tools you need to implement efficient and informative logging.
Contributing #
Contributions are welcome! Feel free to open an issue or submit a pull request on GitHub.
License #
This project is licensed under the MIT License - see the LICENSE file for details.