Logging
Good logging is essential for understanding what your application is doing, debugging issues, and monitoring production systems. Nostos provides a comprehensive logging library with support for multiple output destinations, log levels, and even custom loggers. In this chapter, you'll learn how to effectively use logging in your Nostos applications.
Getting Started
To use the logging library, import it with use stdlib.logging.*. This gives you access to all the logging types and functions. Let's start with the simplest example:
use stdlib.logging.*
main() = {
# Create a console logger
logger = newConsoleLogger()
# Log some messages at different levels
logInfo(logger, "Application started")
logDebug(logger, "Initializing components...")
logWarn(logger, "Configuration file not found, using defaults")
logError(logger, "Failed to connect to database")
# Always flush to ensure output is written
consoleFlush(logger)
0
}
Running this produces formatted output with timestamps and log levels:
[2026-01-15 14:32:01.234] [INFO] Application started [2026-01-15 14:32:01.235] [DEBUG] Initializing components... [2026-01-15 14:32:01.235] [WARN] Configuration file not found, using defaults [2026-01-15 14:32:01.236] [ERROR] Failed to connect to database
Understanding Log Levels
The logging library provides five log levels, ordered from least to most severe. Each level has a specific purpose:
# Log levels are defined as a variant type
type LogLevel = LDebug | LInfo | LWarn | LError | LFatal
| Level | Priority | When to Use |
|---|---|---|
LDebug |
0 (lowest) | Detailed information for debugging. Typically disabled in production. |
LInfo |
1 | General information about application progress and state changes. |
LWarn |
2 | Something unexpected happened, but the application can continue. |
LError |
3 | An error occurred that prevented an operation from completing. |
LFatal |
4 (highest) | A critical error that requires immediate attention or causes shutdown. |
Filtering Logs by Level
In development, you often want to see all log messages. In production, you typically only want warnings and errors. You can set a minimum log level when creating a logger:
use stdlib.logging.*
main() = {
# Create a logger that only shows warnings and above
logger = newConsoleLoggerWithLevel(LWarn)
# These will be filtered out (below minimum level)
logDebug(logger, "This debug message won't appear")
logInfo(logger, "This info message won't appear either")
# These will be shown (at or above minimum level)
logWarn(logger, "This warning will appear")
logError(logger, "This error will appear")
logFatal(logger, "This fatal message will appear")
consoleFlush(logger)
0
}
Output:
[2026-01-15 14:35:12.100] [WARN] This warning will appear [2026-01-15 14:35:12.101] [ERROR] This error will appear [2026-01-15 14:35:12.101] [FATAL] This fatal message will appear
Adding Source Context
In larger applications, it's helpful to know which component or module generated a log message. Use the *From variants to add a source identifier:
use stdlib.logging.*
main() = {
logger = newConsoleLogger()
# Log with source identifiers
logInfoFrom(logger, "Database", "Connection pool initialized with 10 connections")
logInfoFrom(logger, "HTTP", "Server listening on port 8080")
logWarnFrom(logger, "Cache", "Cache miss rate above 50%")
logErrorFrom(logger, "Auth", "Invalid token received from client 192.168.1.100")
consoleFlush(logger)
0
}
Output with source tags:
[2026-01-15 14:40:00.100] [INFO] [Database] Connection pool initialized with 10 connections [2026-01-15 14:40:00.101] [INFO] [HTTP] Server listening on port 8080 [2026-01-15 14:40:00.102] [WARN] [Cache] Cache miss rate above 50% [2026-01-15 14:40:00.103] [ERROR] [Auth] Invalid token received from client 192.168.1.100
Logging to stderr
Sometimes you want to separate log output from regular program output. The StderrLogger writes to standard error instead of standard output:
use stdlib.logging.*
main() = {
# Create a stderr logger (useful for separating logs from program output)
logger = newStderrLogger()
# Log to stderr
stderrLog(logger, makeEntry(LInfo, "This goes to stderr"))
stderrLog(logger, makeEntry(LError, "Errors also go to stderr"))
stderrFlush(logger)
# Regular output still goes to stdout
println("This goes to stdout")
0
}
This is particularly useful when running programs in pipelines, as you can redirect logs separately from program output.
Logging to Files
For long-running applications, you'll want to persist logs to files. The FileLogger appends log entries to a file:
use stdlib.logging.*
main() = {
# Create a file logger - logs are appended to the file
logger = newFileLogger("/var/log/myapp.log")
# Log some messages
fileLogInfo(logger, "Application started")
fileLogInfo(logger, "Processing request from user 42")
fileLogWarn(logger, "Response time exceeded 500ms")
fileLogError(logger, "Failed to write to cache")
# File is automatically flushed after each write
fileFlush(logger)
println("Logs written to /var/log/myapp.log")
0
}
You can also set a minimum log level for file loggers:
use stdlib.logging.*
main() = {
# Only log warnings and above to file
logger = newFileLoggerWithLevel("/var/log/myapp-errors.log", LWarn)
fileLogDebug(logger, "Debug - won't be written")
fileLogInfo(logger, "Info - won't be written")
fileLogWarn(logger, "Warning - will be written")
fileLogError(logger, "Error - will be written")
0
}
Log Rotation
Log files can grow very large over time. The RotatingFileLogger automatically rotates log files when they reach a certain size, keeping a specified number of backup files:
use stdlib.logging.*
main() = {
# Create a rotating file logger
# - Path: /var/log/myapp.log
# - Max size: 10MB (10 * 1024 * 1024 bytes)
# - Keep 5 backup files
maxSize = 10 * 1024 * 1024 # 10 MB
maxBackups = 5
logger = newRotatingFileLogger("/var/log/myapp.log", maxSize, maxBackups)
# Log messages as usual
rotatingLogInfo(logger, "Application started")
rotatingLogInfo(logger, "Processing data...")
# When myapp.log reaches 10MB:
# - myapp.log.5 is deleted (if exists)
# - myapp.log.4 -> myapp.log.5
# - myapp.log.3 -> myapp.log.4
# - myapp.log.2 -> myapp.log.3
# - myapp.log.1 -> myapp.log.2
# - myapp.log -> myapp.log.1
# - New myapp.log is created
0
}
The rotation scheme keeps your most recent logs while automatically cleaning up old ones:
/var/log/ myapp.log # Current log file myapp.log.1 # Previous log file (most recent backup) myapp.log.2 # Older backup myapp.log.3 # Even older backup myapp.log.4 # ... myapp.log.5 # Oldest backup (will be deleted on next rotation)
Working with Log Entries Directly
For more control, you can create and format log entries manually using makeEntry and formatEntry:
use stdlib.logging.*
main() = {
# Create a log entry manually
entry = makeEntry(LInfo, "User logged in")
# The entry contains:
# - level: LInfo
# - message: "User logged in"
# - timestamp: current time in milliseconds
# - source: "" (empty)
# Create an entry with a source
entryWithSource = makeEntryFrom(LWarn, "Disk space low", "Storage")
# Format entries as strings
formatted = formatEntry(entry)
println(formatted)
# Output: [2026-01-15 14:50:00.123] [INFO] User logged in
formattedWithSource = formatEntry(entryWithSource)
println(formattedWithSource)
# Output: [2026-01-15 14:50:00.124] [WARN] [Storage] Disk space low
0
}
The LogEntry Type
Understanding the LogEntry type helps when building custom logging solutions:
# The LogEntry record type
type LogEntry = {
level: LogLevel, # The severity level
message: String, # The log message
timestamp: Int, # Unix timestamp in milliseconds
source: String # Optional source identifier (empty string if not set)
}
# Example: Create a custom log entry
customEntry = LogEntry(
LError,
"Connection timeout after 30 seconds",
Time.now(),
"NetworkClient"
)
# Access fields
println("Level: " ++ levelToString(customEntry.level)) # "ERROR"
println("Message: " ++ customEntry.message)
println("Source: " ++ customEntry.source) # "NetworkClient"
Creating Custom Loggers
The logging library provides a Logger trait that you can implement to create custom loggers. This is useful when you need special formatting, filtering, or output destinations.
Example: A Prefix Logger
Let's create a custom logger that adds a configurable prefix to all messages:
use stdlib.logging.*
# Define our custom logger type
type PrefixLogger = { prefix: String, minLevel: LogLevel }
# Constructor function
newPrefixLogger(prefix) = PrefixLogger(prefix, LDebug)
# Implement the Logger trait
PrefixLogger: Logger
log(self, entry: LogEntry) -> () = {
if levelPriority(entry.level) >= levelPriority(self.minLevel) then
println("[" ++ self.prefix ++ "] " ++ formatEntry(entry))
else ()
}
flush(self) -> () = flushStdout()
end
main() = {
# Create loggers for different components
dbLogger = newPrefixLogger("DATABASE")
httpLogger = newPrefixLogger("HTTP-SERVER")
# Use the loggers with method syntax
dbLogger.log(makeEntry(LInfo, "Connected to PostgreSQL"))
dbLogger.log(makeEntry(LDebug, "Executing query: SELECT * FROM users"))
httpLogger.log(makeEntry(LInfo, "Listening on port 8080"))
httpLogger.log(makeEntry(LWarn, "Slow response: 2500ms"))
dbLogger.flush()
httpLogger.flush()
0
}
Output:
[DATABASE] [2026-01-15 15:00:00.100] [INFO] Connected to PostgreSQL [DATABASE] [2026-01-15 15:00:00.101] [DEBUG] Executing query: SELECT * FROM users [HTTP-SERVER] [2026-01-15 15:00:00.102] [INFO] Listening on port 8080 [HTTP-SERVER] [2026-01-15 15:00:00.103] [WARN] Slow response: 2500ms
Example: A JSON Logger
Here's a more advanced example that outputs logs as JSON for easy parsing by log aggregation tools:
use stdlib.logging.*
use stdlib.json.*
# JSON logger type
type JsonLogger = { minLevel: LogLevel, includeTimestamp: Bool }
newJsonLogger() = JsonLogger(LDebug, true)
newJsonLoggerWithOptions(minLevel, includeTimestamp) = JsonLogger(minLevel, includeTimestamp)
# Implement the Logger trait
JsonLogger: Logger
log(self, entry: LogEntry) -> () = {
if levelPriority(entry.level) >= levelPriority(self.minLevel) then {
# Build JSON object
obj = %{
"level": levelToString(entry.level),
"message": entry.message
}
# Optionally add timestamp
objWithTs = if self.includeTimestamp then
obj.put("timestamp", entry.timestamp)
else obj
# Add source if present
finalObj = if entry.source != "" then
objWithTs.put("source", entry.source)
else objWithTs
println(toJson(finalObj))
} else ()
}
flush(self) -> () = flushStdout()
end
main() = {
logger = newJsonLogger()
logger.log(makeEntry(LInfo, "Server started"))
logger.log(makeEntryFrom(LError, "Connection refused", "Database"))
logger.flush()
0
}
Output (JSON format, one object per line):
{"level":"INFO","message":"Server started","timestamp":1737039600100}
{"level":"ERROR","message":"Connection refused","timestamp":1737039600101,"source":"Database"}
Global Logging with MVar
Sometimes you need a global logger whose configuration can be changed at runtime. You can use
mvar (mutable variables) to hold the log level
or a logger record that can be updated dynamically:
Simple: Just the Log Level
The simplest approach is to store just the log level in an mvar:
use stdlib.logging.*
# Store log level in mvar for runtime changes
mvar logLevel: LogLevel = LDebug
# Global logger that checks the mvar
globalLog(level, message) = {
minLevel = logLevel
if levelPriority(level) >= levelPriority(minLevel) then
println(formatEntry(makeEntry(level, message)))
else ()
}
setGlobalLogLevel(level) = {
logLevel = level
}
main() = {
globalLog(LDebug, "Starting up") # Visible
globalLog(LInfo, "Ready") # Visible
# Change level at runtime
setGlobalLogLevel(LWarn)
globalLog(LDebug, "Debug info") # Filtered out
globalLog(LWarn, "Warning!") # Visible
globalLog(LError, "Error occurred") # Visible
flushStdout()
0
}
Advanced: Logger Record in MVar
For more control, you can store an entire logger record in an mvar. This lets you change multiple settings at once:
use stdlib.logging.*
# Define a logger configuration type
type GlobalLogger = { minLevel: LogLevel }
# Store the logger config in an mvar
mvar logger: GlobalLogger = GlobalLogger(LDebug)
globalLog(level, message) = {
cfg = logger
if levelPriority(level) >= levelPriority(cfg.minLevel) then
println(formatEntry(makeEntry(level, message)))
else ()
}
setLogLevel(level) = {
logger = GlobalLogger(level)
}
main() = {
globalLog(LInfo, "Application started")
globalLog(LDebug, "Debug details")
# Change configuration
setLogLevel(LError)
globalLog(LWarn, "This is filtered")
globalLog(LError, "This is visible")
0
}
Using mvar for global logging state is useful in concurrent applications where multiple processes need to share the same log level configuration. The mvar provides thread-safe access to the shared state.
Best Practices
Follow these guidelines to make your logging more effective:
1. Choose the Right Log Level
# GOOD: Appropriate levels
logDebug(logger, "Cache key: user_42_profile") # Implementation details
logInfo(logger, "User 42 logged in") # Normal events
logWarn(logger, "API rate limit at 80%") # Potential issues
logError(logger, "Payment processing failed") # Actual failures
logFatal(logger, "Database connection lost") # Critical failures
# BAD: Inappropriate levels
logError(logger, "User clicked button") # This is INFO at most
logDebug(logger, "Server crashed!") # This should be FATAL
2. Include Relevant Context
# GOOD: Includes useful context
logInfo(logger, "Order #12345 processed for user 42, total: $99.99")
logError(logger, "Failed to send email to user@example.com: timeout after 30s")
# BAD: Missing context
logInfo(logger, "Order processed") # Which order? For whom?
logError(logger, "Email failed") # To whom? Why?
3. Use Source Identifiers in Large Applications
# GOOD: Easy to filter and trace
logInfoFrom(logger, "OrderService", "Processing order #12345")
logInfoFrom(logger, "PaymentGateway", "Charging card ending in 4242")
logInfoFrom(logger, "EmailService", "Sending confirmation to user@example.com")
# When something goes wrong, you can easily trace the flow:
# [OrderService] Processing order #12345
# [PaymentGateway] Charging card ending in 4242
# [PaymentGateway] ERROR: Card declined
# [OrderService] Order #12345 failed: payment declined
4. Configure Different Levels for Different Environments
use stdlib.logging.*
# Get log level from environment or configuration
getLogLevel(env) = match env {
"development" -> LDebug # Show everything in dev
"staging" -> LInfo # Info and above in staging
"production" -> LWarn # Only warnings and errors in prod
_ -> LInfo # Default to info
}
main() = {
env = getEnv("APP_ENV").getOrElse("development")
level = getLogLevel(env)
logger = newConsoleLoggerWithLevel(level)
logDebug(logger, "Detailed debug info") # Only in development
logInfo(logger, "Server starting...") # Development + staging
logWarn(logger, "High memory usage") # All environments
logError(logger, "Request failed") # All environments
consoleFlush(logger)
0
}
Complete Example: Application with Multiple Loggers
Here's a complete example showing how to set up logging for a real application with both console and file output:
use stdlib.logging.*
# Application logger that writes to both console and rotating file
type AppLogger = {
console: ConsoleLogger,
file: RotatingFileLogger,
appName: String
}
newAppLogger(appName, logPath) = {
console = newConsoleLoggerWithLevel(LInfo)
file = newRotatingFileLoggerWithLevel(
logPath,
LDebug, # File gets all levels
5 * 1024 * 1024, # 5 MB max size
3 # Keep 3 backups
)
AppLogger(console, file, appName)
}
# Log to both console and file
appLog(logger, entry) = {
# Add app name as source if not already set
entryWithSource = if entry.source == "" then
LogEntry(entry.level, entry.message, entry.timestamp, logger.appName)
else entry
# Log to console (respects its level filter)
consoleLog(logger.console, entryWithSource)
# Log to file (respects its level filter)
rotatingFileLog(logger.file, entryWithSource)
}
appFlush(logger) = {
consoleFlush(logger.console)
rotatingFileFlush(logger.file)
}
# Convenience functions
appDebug(logger, msg) = appLog(logger, makeEntry(LDebug, msg))
appInfo(logger, msg) = appLog(logger, makeEntry(LInfo, msg))
appWarn(logger, msg) = appLog(logger, makeEntry(LWarn, msg))
appError(logger, msg) = appLog(logger, makeEntry(LError, msg))
main() = {
# Initialize application logger
logger = newAppLogger("MyApp", "/var/log/myapp/app.log")
appInfo(logger, "Application starting...")
appDebug(logger, "Loading configuration from /etc/myapp/config.json")
appInfo(logger, "Configuration loaded successfully")
appDebug(logger, "Connecting to database...")
appInfo(logger, "Database connection established")
appInfo(logger, "Starting HTTP server on port 8080")
appWarn(logger, "Running in development mode - not for production use")
# Simulate some work
appDebug(logger, "Processing incoming request: GET /api/users")
appInfo(logger, "Request completed in 45ms")
appInfo(logger, "Shutting down gracefully...")
appFlush(logger)
0
}
Console output (INFO and above only):
[2026-01-15 16:00:00.100] [INFO] [MyApp] Application starting... [2026-01-15 16:00:00.102] [INFO] [MyApp] Configuration loaded successfully [2026-01-15 16:00:00.104] [INFO] [MyApp] Database connection established [2026-01-15 16:00:00.105] [INFO] [MyApp] Starting HTTP server on port 8080 [2026-01-15 16:00:00.106] [WARN] [MyApp] Running in development mode - not for production use [2026-01-15 16:00:00.108] [INFO] [MyApp] Request completed in 45ms [2026-01-15 16:00:00.109] [INFO] [MyApp] Shutting down gracefully...
File output (includes DEBUG):
[2026-01-15 16:00:00.100] [INFO] [MyApp] Application starting... [2026-01-15 16:00:00.101] [DEBUG] [MyApp] Loading configuration from /etc/myapp/config.json [2026-01-15 16:00:00.102] [INFO] [MyApp] Configuration loaded successfully [2026-01-15 16:00:00.103] [DEBUG] [MyApp] Connecting to database... [2026-01-15 16:00:00.104] [INFO] [MyApp] Database connection established [2026-01-15 16:00:00.105] [INFO] [MyApp] Starting HTTP server on port 8080 [2026-01-15 16:00:00.106] [WARN] [MyApp] Running in development mode - not for production use [2026-01-15 16:00:00.107] [DEBUG] [MyApp] Processing incoming request: GET /api/users [2026-01-15 16:00:00.108] [INFO] [MyApp] Request completed in 45ms [2026-01-15 16:00:00.109] [INFO] [MyApp] Shutting down gracefully...
Summary
In this chapter, you learned how to:
- Use the built-in
ConsoleLogger,StderrLogger,FileLogger, andRotatingFileLogger - Understand and use the five log levels:
LDebug,LInfo,LWarn,LError,LFatal - Filter logs by setting a minimum level
- Add source context to log messages for easier debugging
- Implement custom loggers using the
Loggertrait - Follow best practices for effective logging
Quick Reference
use stdlib.logging.* - Import the logging library
newConsoleLogger() - Create console logger
newFileLogger(path) - Create file logger
newRotatingFileLogger(path, maxSize, maxBackups) - Create rotating logger
logInfo(logger, msg) - Log at INFO level
logInfoFrom(logger, source, msg) - Log with source
consoleFlush(logger) - Flush output