<p align="center">
<img src="https://raw.githubusercontent.com/lupodevelop/woof/main/assets/img/woof-logo.png" alt="woof logo" width="200" />
</p>
[](https://hex.pm/packages/woof) [](https://hexdocs.pm/woof/) [](https://gleam.run) [](https://opensource.org/licenses/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
```sh
gleam add woof
```
```gleam
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:
```gleam
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:
```gleam
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:
```gleam
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.
```gleam
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.
```gleam
woof.set_format(woof.Json)
```
```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.
```gleam
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.
```gleam
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.
```gleam
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.
```gleam
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:
```gleam
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 `await`s), 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:
```gleam
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`:
```gleam
woof.configure(woof.Config(
level: woof.Info,
format: woof.Json,
colors: woof.Auto,
))
```
Or change individual settings:
```gleam
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:
```gleam
// Write to a file (simplified example)
woof.set_sink(fn(_entry, formatted) {
simplifile.append(log_path, formatted <> "\n")
})
```
```gleam
// Send structured data to an external service
woof.set_sink(fn(entry, _formatted) {
send_to_datadog(entry.level, entry.message, entry.fields)
})
```
```gleam
// 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:
```gleam
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`](https://www.erlang.org/doc/apps/kernel/logger_chapter.html)
module:
```gleam
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:
- Apps that use woof run **two independent logging systems** in parallel.
- It is impossible to silence or redirect woof output — even from libraries
that use woof as a dependency.
- BEAM logger features (async dispatch, load-shedding, handler routing) do
not apply to woof messages.
- External log collectors (Loki, Datadog, etc.) only see half your logs.
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):
```erlang
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:
```text
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:
```erlang
logger:set_handler_config(default, formatter, {logger_formatter, #{
template => [level, " ", time, " ", msg, "\n"],
single_line => true
}}).
```
In Elixir (`config/config.exs`):
```elixir
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 |
|------------|-----------------|
| `Debug` | `console.debug` |
| `Info` | `console.info` |
| `Warning` | `console.warn` |
| `Error` | `console.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:
- `Auto` (default) — colors are enabled when stdout is a TTY and `NO_COLOR`
is not set.
- `Always` — force ANSI colors regardless of environment.
- `Never` — plain text, no escape codes.
```gleam
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.
```gleam
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:
```gleam
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:
```gleam
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:
```gleam
fetch_data()
|> woof.log_error("Fetch failed", [#("endpoint", url)])
|> result.unwrap(default)
```
### time
Measure and log the duration of a block:
```gleam
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_debug`…`tap_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_name` | `Warning` → `"warning"` (useful in formatters) |
## Cross-platform
woof works on both the Erlang and JavaScript targets.
- **Erlang**: global state uses `persistent_term` (part of `erts`, always
available). Scoped context lives in the process dictionary.
The default sink routes output through OTP `logger` — see
[BEAM logger integration](#beam-logger-integration) above.
- **JavaScript**: module-level variables. Safe because JS is
single-threaded. The default sink uses woof's own formatting and writes
via `console.debug` / `console.info` / `console.warn` / `console.error`.
Structured fields, namespaces, context, lazy evaluation, and pipeline
helpers behave identically on both targets.
## Dependencies & Requirements
* Gleam **1.14** or newer (tested with 1.14.0).
* OTP 22+ on the BEAM (CI uses OTP 28).
* Just `gleam_stdlib` — no runtime dependencies.
---
<p align="center">Made with Gleam 💜</p>