v1.10 adds typed sink fan-out and lazy instance logging, fixes JSON control character escaping and nested redaction, and restores scoped context and traces after exceptions. Existing APIs and defaults are preserved.

woof logo

Package Version Hex Docs Built with Gleam License: 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. Structured fields, namespaces, scoped context, typed events - all there when you need them, invisible when you don’t.

Install

gleam add woof

Quick start

import woof

pub fn main() {
  woof.info("Server started", [woof.str("host", "0.0.0.0"), woof.int("port", 3000)])
  woof.warning("Cache almost full", [woof.int("usage_pct", 92)])
  woof.error("Connection lost", [woof.str("host", "db-primary")])
}
[INFO] 10:30:45 Server started
  host: 0.0.0.0
  port: 3000
[WARN] 10:30:46 Cache almost full
  usage_pct: 92
[ERROR] 10:30:47 Connection lost
  host: db-primary

No setup, no builder chains, no ceremony.

Typed fields

Fields carry their original Gleam types through the entire pipeline. Pattern-match on them in event sinks, assert on them in tests.

woof.info("Payment processed", [
  woof.str("order_id", "ORD-42"),
  woof.int("amount_cents", 4999),
  woof.float("tax_rate", 8.5),
  woof.bool("express", True),
])

Nested data

Lists, nested objects, and explicit null pass through as typed values:

woof.info("order", [
  woof.str("id", "ORD-42"),
  woof.list("items", [woof.vstr("widget"), woof.vstr("gadget")]),
  woof.map("address", [
    #("city", woof.vstr("Bologna")),
    #("zip",  woof.vstr("40121")),
  ]),
  woof.null("coupon"),
])

JSON output emits real types: "items":["widget","gadget"], "address":{...}, "coupon":null. Numbers are numbers, booleans are booleans.

Testing capture typed events

let #(sink, get) = woof.test_sink()
woof.set_sink(woof.silent_sink)
woof.set_event_sink(sink)

process_payment(order_id: "ORD-99", amount: 0)

let assert [event] = get()
event.level   |> should.equal(woof.Error)
event.message |> should.equal("Payment rejected")
event.fields  |> should.equal([
  #("order_id", woof.FString("ORD-99")),
  #("reason",   woof.FString("zero amount")),
])

One-call setup

pub fn main() {
  woof.dev()   // Debug level, Text format, colors Auto, stdout
  // - or -
  woof.prod()  // Info level, Json format, OTP logger
}

Or wire up sinks explicitly:

woof.set_sinks([woof.beam_logger_sink, my_metrics_sink])
woof.set_event_sink(woof.beam_event_sink) // structured typed fields to OTP

Instanced loggers with context

Pass a fixed set of fields through a logger instance - no global state needed:

let db = woof.new("database") |> woof.set_context([woof.str("component", "db")])
db |> woof.log(woof.Info, "Connected", [woof.str("host", "localhost")])
// → namespace: "database", fields: component="db", host="localhost"

Defer both the message and fields when diagnostics are expensive:

db |> woof.log_lazy(woof.Debug, fn() {
  #("Query details", [woof.str("query", expensive_query_description())])
})

The builder runs once when the level is enabled, and is skipped otherwise.

Build hierarchies with child (inherits parent context, dot-joined namespace):

let http   = woof.new("http")
let router = woof.child(http, "router")   // namespace: "http.router"

Ideal for JS async code where global context is unreliable.

Selective sink routing

Send typed events to several destinations, in order:

woof.set_sinks([])
woof.set_event_sink(woof.fanout_event_sink([
  woof.beam_event_sink,
  audit_sink,
]))

Fan-out is synchronous. A failing sink does not block later destinations; a slow sink delays them. set_sinks([]) disables the independent legacy output.

Wrap a sink with a predicate to send only matching events somewhere:

woof.set_event_sink(woof.filter_event_sink(
  fn(e) { woof.level_to_int(e.level) >= woof.level_to_int(woof.Error) },
  pagerduty_sink,
))

Production hardening

Redact secrets, cap volume, and batch deliveries by composing sink wrappers - redact first, batch last:

let batched = woof.batch_event_sink(200, 5000, send_batch)
let sampled = woof.consistent_sample_event_sink(0.1, "trace_id", woof.Error, batched)
let limited = woof.rate_limit_event_sink(2000, sampled)
woof.set_sinks([])
woof.set_event_sink(woof.redact_event_sink(["password", "authorization"], limited))

send_batch receives List(woof.LogEvent). Build wrappers from the destination outward; events run through redaction, rate limiting, sampling, then batching. Redaction traverses maps and lists and protects only the wrapped destination. Partial batches flush on a later event when a threshold is met; there is no timer or automatic shutdown flush.

error_with and log_at_most cover two more common cases: a structured error field, and capping a noisy log line without an if at every call site.

woof.error_with("payment failed", "timeout", [woof.str("order_id", "O1")])
woof.log_at_most(5, "db_retry_failed", woof.Warning, "retry failed", [])

See docs/production_setup.md for the reasoning behind the composition order and docs/guide.md for the full reference.

Debugging helpers

fetch_user(id)
|> woof.inspect("user")          // logs string repr at Debug, passes value through
|> woof.tap_time("after_fetch")  // logs monotonic_ms as Int field at Debug
|> transform()

Level from environment variable

pub fn main() {
  let _ = woof.set_level_from_env("LOG_LEVEL")  // reads LOG_LEVEL, falls back silently
  // ...
}
LOG_LEVEL=warning ./my_app   # sets Warning level at startup

Parse or inspect the level anywhere:

woof.level_from_string("critical")  // Ok(Critical)
woof.get_level()                    // current Level

Trace correlation and OpenTelemetry

Tie logs to a distributed trace. Inside with_trace, every log carries trace_id and span_id:

woof.with_trace(trace_id, span_id, fn() {
  woof.info("payment captured", [woof.int("amount_cents", 4200)])
})

with_trace and with_context restore the previous scope even if the callback raises; the error still propagates. On JavaScript these scopes cover synchronous callbacks. Use logger instances for asynchronous work.

Set OpenTelemetry resource attributes once at startup, then switch the format to OtlpJson for an OpenTelemetry-shaped object per line. This is a per-event JSON representation; an adapter supplies export framing and transport:

woof.set_resource([
  woof.str("service.name", "checkout"),
  woof.str("service.version", "1.8.0"),
])
woof.configure(woof.Config(woof.Info, woof.OtlpJson, woof.Never))
{"timestamp_unix_nano":1779000000000000000,"severity_number":9,"severity_text":"INFO","body":"payment captured","trace_id":"...","span_id":"...","resource":{"service.name":"checkout"},"attributes":{"amount_cents":4200}}

Documentation

DocumentContents
docs/guide.mdFull reference: levels, formats, sinks, context, BEAM integration, API table
docs/production_setup.mdComposing v1.9 sink wrappers for production
docs/sink_composition.mdWhy sink wrapper order matters
docs/benchmarks.mdMeasured overhead of each sink wrapper
docs/semantic_conventions.mdStandard field names for queryable logs
docs/log_levels.mdChoosing the right log level
docs/migration_v1_3.mdUpgrading from v1.2 - what changed and how to fix it
ROADMAP.mdRelease history and planned v2.0 cleanup
CHANGELOG.mdRelease history
hexdocs.pm/woofGenerated module reference

Requirements


Made with Gleam 💜

✨ Search Document