contextual 2.2.0 copy "contextual: ^2.2.0" to clipboard
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.

pub package Dart License Build Status Buy Me A Coffee

Contextual #

A structured logging library for Dart

Screenshot

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:

  1. 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();
    
  2. 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.
  • Daily File Driver

    • Driver Name: daily
    • Configuration Options:
      • path: The file path where logs will be stored. Default is logs/default.log.
      • days: Number of days to retain log files. Default is 14.
  • Webhook Driver

    • Driver Name: webhook
    • Configuration Options:
      • url: The webhook URL to send logs to.
  • 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 a driver key.
  • 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 is false.

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.

8
likes
160
points
5.73k
downloads

Publisher

verified publisherglenfordwilliams.com

Weekly Downloads

A structured logging library for Dart with support for multiple output channels, customizable formatting, context management, and middleware processing.

Repository (GitHub)
View/report issues

Topics

#logging #structured-logging #middleware #observability #telemetry

Documentation

API reference

Funding

Consider supporting this project:

www.buymeacoffee.com

License

MIT (license)

Dependencies

ansicolor, dart_mappable, gato, intl, meta, universal_io

More

Packages that depend on contextual