Alpha. Vary is under active development and not ready for production use. Syntax, APIs, performance, and behaviour may change between releases.

Structured logging

How it works

Log statements are part of Vary's grammar. The compiler parses them, validates field types, checks purity constraints, and generates level-gated bytecode. At runtime, events are dispatched to a configurable sink.

There is nothing to import. You write log info "event_id" "message" and the compiler handles the rest.

Log statements

A log statement has a level, a stable event ID, and an optional message:

log info "app_started" "Server is running"
log debug "cache_miss" "Key not found"
log error "db_timeout" "Connection timed out"

The short form takes the level, event ID (a string literal), and an optional message expression. The event ID is a stable identifier for this log point. It does not change when you reword the message.

Log levels

Five levels are available, from lowest to highest priority:

LevelUse
traceDetailed diagnostic output
debugInternal state useful during development
infoNormal operational events
warnUnexpected but recoverable situations
errorFailures that need attention

The runtime filters events below the active level. Setting the level to warn silences trace, debug, and info events.

Structured fields

For richer events, use the block form to attach typed fields:

let name = "alice"
let count = 3
let elapsed = 0.042

log info "user_login" {
    message = "User logged in"
    field "username" = name
    field "attempt" = count
    field "latency_ms" = elapsed * 1000.0
}

Field values must be one of four primitive types:

TypeExample
Intfield "port" = 8080
Floatfield "latency" = 3.14
Boolfield "cached" = True
Strfield "user" = name

The compiler rejects complex types (lists, dicts, objects) at compile time. This keeps log events flat, avoids serialization overhead, and means any sink can write them without custom encoders.

Logger blocks

Logger blocks scope a name to all nested log statements:

logger "auth" {
    log info "login_attempt" "Checking credentials"
    log info "login_success" "Authenticated"
}

The compiler propagates the logger name into each nested log statement. Logger blocks can nest, and names are joined with dots:

logger "app" {
    logger "auth" {
        log info "check" "Verifying token"
        # logger name: app.auth
    }
}

Context blocks

Context blocks attach fields that propagate to every log statement inside them:

logger "api" {
    context {
        field "service" = "orders"
        field "version" = "2.1"
    }
    log info "request_start" "Handling request"
    # event includes service="orders" and version="2.1"
}

Context fields are merged into each log event at compile time. If a log statement defines a field with the same name as a context field, the log statement's value takes precedence.

Sinks

Five sinks are built in:

SinkOutputDefault
stdoutPlain text, one line per eventYes
colorANSI-colored output for terminals
jsonOne JSON object per line
memoryIn-memory buffer for testing
noneDiscards all events

Switch sinks at runtime:

import log

log.set_sink("json")
log info "test_event" "Testing JSON output"
log.set_sink("color")
log info "test_event" "Testing color output"

Level configuration

Set the minimum level at runtime:

import log

log.set_level("debug")   # show debug and above
log debug "test" "This is visible"
log.set_level("warn")    # show warn and error only
log debug "test" "This is hidden"
log warn "test" "This is visible"

The default level is info.

Testing with capture

For testing, capture log events in memory instead of printing them:

import log

test "login logs username" {
    log.set_sink("none")
    log.capture()

    log info "login" {
        message = "User logged in"
        field "username" = "alice"
    }

    let events = log.events()
    observe len(events) == 1
}

log.capture() starts collecting events on the current thread. log.events() returns the collected events as a list and clears the buffer. Events are captured regardless of the active sink, so you can set the sink to "none" to suppress output during tests.

Purity

Log statements are side effects. The compiler rejects them inside pure functions:

pure def add(a: Int, b: Int) -> Int {
    log info "adding" "Computing sum"  # compile error
    return a + b
}

This is enforced by the effect system. A function that logs cannot be pure.

Mutation testing

The compiler treats log statements specially during mutation testing.

Event IDs and message strings are not mutated. Changing "user_login" to "user_logout" would not test anything meaningful about correctness. Field value expressions are mutated normally: if your code logs field "count" = len(items), the mutator may replace len(items) with 0 to see if any test notices.

A library-based logger would be opaque to the mutation engine. Because log is grammar, the compiler can tell the difference between a label and a value.

Built-in functions

FunctionSignaturePurpose
capture_logs()() -> NoneStart capturing log events on the current thread
captured_events()() -> ListReturn captured events and clear the buffer
log_set_level(level)(Str) -> NoneSet minimum log level ("trace", "debug", "info", "warn", "error")
log_set_sink(sink)(Str) -> NoneSet active sink ("stdout", "color", "json", "memory", "none")