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, and RotatingFileLogger
  • 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 Logger trait
  • 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