woof logo

Package VersionHex DocsBuilt with GleamLicense: MIT

woof

A straightforward logging library for Gleam.
Dedicated to Echo, my dog.

woof gets out of your way: import it, call info(...), and you're done. When you need more structured fields, namespaces, scoped context. It's all there without changing the core workflow.

Quick start

gleam add woof
import woof

pub fn main() {
  woof.info("Server started", [#("port", "3000")])
  woof.warning("Cache almost full", [#("usage", "92%")])
}

Output:

[INFO] 10:30:45 Server started
  port: 3000
[WARN] 10:30:46 Cache almost full
  usage: 92%

That's it. No setup, no builder chains, no ceremony.

Structured fields

Every log function accepts a list of #(String, String) tuples. Use the built-in field helpers to skip manual conversion:

import woof

woof.info("Payment processed", [
  woof.field("order_id", "ORD-42"),
  woof.int_field("amount", 4999),
  woof.float_field("tax", 8.5),
  woof.bool_field("express", True),
])

Plain tuples still work if you prefer — the helpers are just convenience:

woof.info("Request", [#("method", "GET"), #("path", "/api")])

Available helpers: field, int_field, float_field, bool_field.

Levels

Four levels, ordered by severity:

Level Tag When to use
Debug[DEBUG] Detailed info useful during development
Info[INFO] Normal operational events
Warning[WARN] Something unexpected but not broken
Error[ERROR] Something is wrong and needs attention

Set the minimum level to silence the noise:

woof.set_level(woof.Warning)

woof.debug("ignored", [])      // dropped — below Warning
woof.info("also ignored", [])  // dropped
woof.warning("shown", [])      // printed
woof.error("shown too", [])    // printed

Formats

Text (default)

Human-readable, great for development.

woof.set_format(woof.Text)
[INFO] 10:30:45 User signed in
  user_id: u_123
  method: oauth

JSON

Machine-readable, one object per line — ideal for production and tools like Loki, Datadog, or CloudWatch.

woof.set_format(woof.Json)
{"level":"info","time":"2026-02-11T10:30:45.123Z","msg":"User signed in","user_id":"u_123","method":"oauth"}

Custom

Plug in any function that takes an Entry and returns a String. This is the escape hatch for integrating with other formatting or output libraries.

let my_format = fn(entry: woof.Entry) -> String {
  woof.level_name(entry.level) <> " | " <> entry.message
}

woof.set_format(woof.Custom(my_format))

Compact

Single-line, key=value pairs — a compact middle ground.

woof.set_format(woof.Compact)
INFO 2026-02-11T10:30:45.123Z User signed in user_id=u_123 method=oauth

Namespaces

Organise log output by component without polluting the message itself.

let log = woof.new("database")

log |> woof.log(woof.Info, "Connected", [#("host", "localhost")])
log |> woof.log(woof.Debug, "Query executed", [#("ms", "12")])
[INFO] 10:30:45 database: Connected
  host: localhost
[DEBUG] 10:30:45 database: Query executed
  ms: 12

In JSON output the namespace appears as the "ns" field.

Context

Scoped context

Attach fields to every log call inside a callback. Perfect for request-scoped metadata.

use <- woof.with_context([#("request_id", req.id)])

woof.info("Handling request", [])   // includes request_id
do_work()
woof.info("Done", [])              // still includes request_id

On the BEAM each process (= each request handler) gets its own context via the process dictionary, so concurrent handlers never interfere.

Nesting works — inner contexts accumulate on top of outer ones:

use <- woof.with_context([#("service", "api")])
use <- woof.with_context([#("request_id", id)])

woof.info("Processing", [])
// fields: service=api, request_id=<id>

Notice for JavaScript async users
On the BEAM, with_context uses the process dictionary, so concurrent requests never interfere. On the JavaScript target, because JS is fundamentally single-threaded with cooperative concurrency, with_context modifies a global state. If your with_context callback returns a Promise (or does asynchronous awaits), the context might leak or be overwritten by other concurrent async operations. If you heavily rely on async/await in Node/Deno for concurrent requests, consider passing context explicitly instead of using with_context.

Global context

Set fields that appear on every message, everywhere:

woof.set_global_context([
  #("app", "my-service"),
  #("version", "1.2.0"),
  #("env", "production"),
])

You can also read the current context with woof.get_global_context() or incrementally add fields using woof.append_global_context([#("key", "value")]).

Configuration

For one-shot setup, use configure:

woof.configure(woof.Config(
  level: woof.Info,
  format: woof.Json,
  colors: woof.Auto,
))

Or change individual settings:

woof.set_level(woof.Info)
woof.set_format(woof.Json)
woof.set_colors(woof.Never)

Sinks

A sink is a function fn(Entry, String) -> Nil that receives each log event. It gets both the structured Entry (level, message, fields, namespace, timestamp) and the string that woof's formatter produced.

The active sink is set with set_sink. woof ships two ready-made sinks:

Sink When to use
default_sink Development, scripts, CLI tools (default)
beam_logger_sink Production OTP applications
silent_sink Discard all logs (useful for test suites)

Custom sinks

Use set_sink to replace the active sink with any side-effecting function:

// Write to a file (simplified example)
woof.set_sink(fn(_entry, formatted) {
  simplifile.append(log_path, formatted <> "\n")
})
// Send structured data to an external service
woof.set_sink(fn(entry, _formatted) {
  send_to_datadog(entry.level, entry.message, entry.fields)
})
// Extend an existing sink rather than replacing it
woof.set_sink(fn(entry, formatted) {
  metrics.increment(woof.level_name(entry.level) <> ".count")
  woof.default_sink(entry, formatted)
})

Capturing output in tests

Custom sinks are the idiomatic way to capture log output in tests without touching stdout:

import gleam/erlang/process

pub fn my_test() {
  let subject = process.new_subject()

  woof.set_sink(fn(entry, _formatted) {
    process.send(subject, entry)
  })

  woof.info("something happened", [#("key", "value")])

  let assert Ok(entry) = process.receive(subject, 0)
  let assert "something happened" = entry.message
}

BEAM logger integration

woof's default sink prints directly to stdout — zero configuration, beautiful coloured output, works everywhere.

For production OTP applications, swap in beam_logger_sink once at startup to route every log event through OTP's logger module:

pub fn main() {
  woof.set_sink(woof.beam_logger_sink)

  woof.info("Server started", [woof.int_field("port", 3000)])
}

One line. That is all.

Why bother?

Without beam_logger_sink, woof bypasses the OTP logging pipeline entirely:

With beam_logger_sink all of that goes away: one pipeline, full control.

Filtering and routing

Each event is tagged with domain => [woof] so handlers and primary filters can target woof output specifically.

Silence all woof output (e.g. during tests or in certain environments):

logger:add_primary_filter(no_woof,
    {fun logger_filters:domain/2, {stop, sub, [woof]}}).

Metadata

Each event carries the following logger metadata:

Key Value
domain[woof] — lets filters target woof events
fields The structured #(String, String) field list
namespace The logger namespace, if woof.new/1 was used

Output format

When beam_logger_sink is active, the OTP logger handler owns the output format. The default handler (logger_formatter) wraps the message with its own timestamp and level prefix:

2026-03-22T10:30:45.123+00:00 info:
Server started

woof's Text/Compact/JSON format setting does not affect this output — it only applies when default_sink (or a custom sink) is active.

To customise the OTP format, reconfigure the default handler. In Erlang:

logger:set_handler_config(default, formatter, {logger_formatter, #{
    template => [level, " ", time, " ", msg, "\n"],
    single_line => true
}}).

In Elixir (config/config.exs):

config :logger, :default_handler,
  formatter: {Logger.Formatter, %{
    format: [:level, " ", :time, " ", :message, "\n"]
  }}

JavaScript target

On JavaScript there is no centralised logger equivalent to OTP's logger. beam_logger_sink on JS routes each event to the level-appropriate console method so browser DevTools and Node.js can filter by severity:

woof level console method
Debugconsole.debug
Infoconsole.info
Warningconsole.warn
Errorconsole.error

woof's own formatting (Text, Compact, JSON, Custom) is preserved — the formatted string is what gets passed to the console method.

Colors

Colors apply to Text format only. Three modes:

woof.set_colors(woof.Always)

Level colors: Debug → dim grey, Info → blue, Warning → yellow, Error → bold red.

Lazy evaluation

When building the log message is expensive, use the lazy variants. The thunk is only called if the level is enabled.

woof.debug_lazy(fn() { expensive_debug_dump(state) }, [])

Available: debug_lazy, info_lazy, warning_lazy, error_lazy.

You can also manually check if a level is enabled before doing setup work:

if woof.is_enabled(woof.Debug) {
  let complex_data = do_expensive_work()
  woof.debug("Done", [woof.field("data", complex_data)])
}

Pipeline helpers

tap

Log and pass a value through — fits naturally in pipelines:

fetch_user(id)
|> woof.tap_info("Fetched user", [])
|> transform_user()
|> woof.tap_debug("Transformed", [])
|> save_user()

Available: tap_debug, tap_info, tap_warning, tap_error.

log_error

Log only when a Result is Error, then pass it through:

fetch_data()
|> woof.log_error("Fetch failed", [#("endpoint", url)])
|> result.unwrap(default)

time

Measure and log the duration of a block:

use <- woof.time("db_query")
database.query(sql)

Emits: db_query completed with a duration_ms field.

API at a glance

Function Purpose
debug Log at Debug level
info Log at Info level
warning Log at Warning level
error Log at Error level
debug_lazy Lazy Debug — thunk only runs when enabled
info_lazy Lazy Info
warning_lazy Lazy Warning
error_lazy Lazy Error
new Create a namespaced logger
log Log through a namespaced logger
configure Set level + format + colors at once
set_level Change the minimum level
set_format Change the output format
set_colors Change color mode (Auto/Always/Never)
set_global_context Set app-wide fields
... See get_global_context, append_global_context
set_sink Replace the output sink
default_sink The built-in sink (BEAM logger / console)
silent_sink Discard all logs
is_enabled Check if a log level is currently enabled
with_context Scoped fields for a callback
tap_debugtap_error Log and pass a value through
log_error Log on Result Error, pass through
time Measure and log a block's duration
field#(String, String) — string field
int_field#(String, String) — from Int
float_field#(String, String) — from Float
bool_field#(String, String) — from Bool
format Format an entry without emitting it
level_nameWarning"warning" (useful in formatters)

Cross-platform

woof works on both the Erlang and JavaScript targets.

Structured fields, namespaces, context, lazy evaluation, and pipeline helpers behave identically on both targets.

Dependencies & Requirements


Made with Gleam 💜