Complete runs rather than isolated features. Each example is self-contained – copy it, run it, change it. The Features shown line under each heading links back to the Get started section that explains the piece in question.
Nightly ETL
The shape most pipelines have: a top-level job, a few phases, leaves reporting what each phase did. Nothing here is configured – the nesting comes from the call structure alone.
Features shown: steps, leaf lines, status elevation, the run digest
logtree_reset()
extract <- function() {
log_step("Extract")
log_info("connecting to warehouse")
log_info("24,318 rows pulled")
log_success("staged")
}
transform <- function() {
log_step("Transform")
log_info("normalising currencies")
log_warn("112 rows missing fx rate, carried forward")
log_success("24,318 rows transformed")
}
load_rows <- function() {
log_step("Load")
log_info("upserting into analytics.sales")
log_success("24,318 rows written")
}
nightly_etl <- function() {
log_step("Nightly ETL")
extract()
transform()
load_rows()
}
with_logging(nightly_etl())
#> ▶ Nightly ETL
#> ├─ ▶ Extract
#> │ ├─ ℹ connecting to warehouse
#> │ ├─ ℹ 24,318 rows pulled
#> │ ├─ ✔ staged
#> │ └─ ✔ Done 0.01s
#> ├─ ▶ Transform
#> │ ├─ ℹ normalising currencies
#> │ ├─ ⚠ 112 rows missing fx rate, carried forward
#> │ ├─ ✔ 24,318 rows transformed
#> │ └─ ⚠ Done 0.00s
#> ├─ ▶ Load
#> │ ├─ ℹ upserting into analytics.sales
#> │ ├─ ✔ 24,318 rows written
#> │ └─ ✔ Done 0.01s
#> └─ ✔ Done 0.02s
#> ✔ Run complete in 0.02s
logtree_summary()
#>
#> ── Summary: 1 warning ──────────────────────────────────────────────────────────
#> ⚠ Nightly ETL › Transform › 112 rows missing fx rate, carried forwardThe Transform step closes with a warning glyph even
though it returned normally and reported success afterwards – one
log_warn() was enough to elevate it, and elevation never
moves back down. The digest is the part you read when the run is forty
steps long instead of three.
A migration that fails
An error that nobody catches. with_logging() sees the
condition before the stack unwinds, so every step still open is marked
failed and the message is logged at the depth it happened – then the
error is rethrown unchanged.
Features shown: uncaught errors, the run digest
logtree_reset()
backup <- function() {
log_step("Backup")
log_success("snapshot analytics-2026-02-11 written")
}
apply_migration <- function() {
log_step("Apply migration 0042")
log_info("adding column users.tier")
log_warn("table lock held 800ms")
stop("constraint violation on users.email")
}
release <- function() {
log_step("Release v2.1")
backup()
apply_migration()
log_success("released") # never reached
}
with_logging(release())
#> ▶ Release v2.1
#> ├─ ▶ Backup
#> │ ├─ ✔ snapshot analytics-2026-02-11 written
#> │ └─ ✔ Done 0.00s
#> ├─ ▶ Apply migration 0042
#> │ ├─ ℹ adding column users.tier
#> │ ├─ ⚠ table lock held 800ms
#> │ ├─ ✖ constraint violation on users.email
#> │ └─ ✖ Done 0.00s
#> └─ ✖ Done 0.01s
#> ✖ Run failed in 0.01s
#> Error in `apply_migration()`:
#> ! constraint violation on users.email
logtree_summary()
#>
#> ── Summary: 1 error, 1 warning ─────────────────────────────────────────────────
#> ⚠ Release v2.1 › Apply migration 0042 › table lock held 800ms
#> ✖ Release v2.1 › Apply migration 0042 › constraint violation on users.emailBackup keeps its tick: it had already closed
successfully when the migration blew up, and the error handler only
marks steps that are still open. That is the difference between “the run
failed” and “this step failed”, and it is what makes the tree readable
after the fact.
Wrapping the call in tryCatch() works exactly as it
would without logtree – with_logging() rethrows rather than
swallowing:
logtree_reset()
result <- tryCatch(with_logging(release(), summary = FALSE),
error = function(e) conditionMessage(e))
#> ▶ Release v2.1
#> ├─ ▶ Backup
#> │ ├─ ✔ snapshot analytics-2026-02-11 written
#> │ └─ ✔ Done 0.00s
#> ├─ ▶ Apply migration 0042
#> │ ├─ ℹ adding column users.tier
#> │ ├─ ⚠ table lock held 800ms
#> │ ├─ ✖ constraint violation on users.email
#> │ └─ ✖ Done 0.00s
#> └─ ✖ Done 0.01s
result
#> [1] "constraint violation on users.email"Importing many files
Twenty steps that are all the same kind of thing. Grouping collapses the adjacent ones under a header instead of stacking twenty siblings.
Features shown: grouping
logtree_reset()
load_file <- function(dataset, file, rows, ok = TRUE) {
log_step(file, group = dataset)
log_info(sprintf("%s rows read", format(rows, big.mark = ",")))
if (ok) log_success("merged") else log_error("schema mismatch, skipped")
}
import_all <- function() {
log_step("Import datasets")
load_file("sales", "2023.csv", 18422)
load_file("sales", "2024.csv", 24318)
load_file("returns", "2024.csv", 1204, ok = FALSE)
load_file("returns", "2025.csv", 318)
load_file("sales", "2025.csv", 9871) # not adjacent: a fresh group
}
with_logging(import_all())
#> ▶ Import datasets
#> ├─ ▣ sales
#> │ ├─ ▶ 2023.csv
#> │ │ ├─ ℹ 18,422 rows read
#> │ │ ├─ ✔ merged
#> │ │ └─ ✔ Done 0.00s
#> │ ├─ ▶ 2024.csv
#> │ │ ├─ ℹ 24,318 rows read
#> │ │ ├─ ✔ merged
#> │ │ └─ ✔ Done 0.00s
#> │ └─ ✔ Done 0.01s
#> ├─ ▣ returns
#> │ ├─ ▶ 2024.csv
#> │ │ ├─ ℹ 1,204 rows read
#> │ │ ├─ ✖ schema mismatch, skipped
#> │ │ └─ ✖ Done 0.00s
#> │ ├─ ▶ 2025.csv
#> │ │ ├─ ℹ 318 rows read
#> │ │ ├─ ✔ merged
#> │ │ └─ ✔ Done 0.00s
#> │ └─ ✖ Done 0.01s
#> ├─ ▣ sales
#> │ ├─ ▶ 2025.csv
#> │ │ ├─ ℹ 9,871 rows read
#> │ │ ├─ ✔ merged
#> │ │ └─ ✔ Done 0.00s
#> │ └─ ✔ Done 0.00s
#> └─ ✔ Done 0.02s
#> ✔ Run complete in 0.02s
logtree_summary()
#>
#> ── Summary: 1 error ────────────────────────────────────────────────────────────
#> ✖ Import datasets › returns › 2024.csv › schema mismatch, skippedTwo things to notice. The returns group closes with an
error glyph, because a group’s status is aggregated from its members –
collapsing a group never hides a failure inside it. And the last
sales file opens a second sales
header rather than rejoining the first: grouping is adjacency-based, so
the tree stays in the order things actually happened.
A recovered failure
log_error() records a failure without throwing. When the
recovery works, close the step explicitly to say so – the error stays on
the record, but the step reports the outcome it actually reached.
Features shown: status elevation, manual step control
logtree_reset()
connect <- function() {
log_step("Connect to database")
log_error("primary unreachable (timeout after 5s)")
log_info("failing over to replica db-2")
log_success("connected, 12ms latency")
log_close(status = "success")
}
query <- function() {
log_step("Run report query")
log_success("4,120 rows")
}
report <- function() {
log_step("Daily report")
connect()
query()
}
with_logging(report())
#> ▶ Daily report
#> ├─ ▶ Connect to database
#> │ ├─ ✖ primary unreachable (timeout after 5s)
#> │ ├─ ℹ failing over to replica db-2
#> │ ├─ ✔ connected, 12ms latency
#> │ └─ ✔ Done 0.00s
#> ├─ ▶ Run report query
#> │ ├─ ✔ 4,120 rows
#> │ └─ ✔ Done 0.01s
#> └─ ✔ Done 0.01s
#> ✔ Run complete in 0.01s
logtree_summary()
#>
#> ── Summary: 1 error ────────────────────────────────────────────────────────────
#> ✖ Daily report › Connect to database › primary unreachable (timeout after 5s)The step closes green, but the digest still lists the error – which is the right split. The run succeeded; something went wrong on the way, and whoever reads the log afterwards should know about the failover.
A CI build log
A build runner strips ANSI, wraps lines, and greps the output. The
"ci" preset is built for exactly that: bracketed word
glyphs, pure-ASCII connectors, no colour anywhere.
Features shown: themes, output sinks
logtree_reset()
logtree_theme("ci")
build <- function() {
log_step("Build")
log_info("compiling 84 source files")
log_warn("3 deprecation warnings")
log_success("artifact 12.4 MB")
}
test <- function() {
log_step("Test")
log_info("running 312 tests")
log_error("2 failures in test-parser.R")
}
ci <- function() {
log_step("Pipeline")
build()
test()
}
with_logging(ci(), summary = FALSE)
#> [step] Pipeline
#> |- [step] Build
#> | |- [info] compiling 84 source files
#> | |- [warn] 3 deprecation warnings
#> | |- [ok] artifact 12.4 MB
#> | \- [warn] 0.00s
#> |- [step] Test
#> | |- [info] running 312 tests
#> | |- [fail] 2 failures in test-parser.R
#> | \- [fail] 0.00s
#> \- [done] 0.00s
logtree_summary()
#>
#> -- Summary: 1 error, 1 warning -------------------------------------------------
#> [warn] Pipeline > Build > 3 deprecation warnings
#> [fail] Pipeline > Test > 2 failures in test-parser.R
logtree_theme("unicode")A failure greps as [fail], and the words are different
lengths on purpose – each declares its true width, so the message column
still lines up.
To keep a copy on disk as well as on stdout, register a text file sink. Text sinks always render through the ascii preset, so the file is stable whatever the console is themed as:
log_path <- tempfile(fileext = ".log")
h <- logtree_sink_file(log_path, format = "text")
logtree_reset()
with_logging(ci(), summary = FALSE)
#> ▶ Pipeline
#> ├─ ▶ Build
#> │ ├─ ℹ compiling 84 source files
#> │ ├─ ⚠ 3 deprecation warnings
#> │ ├─ ✔ artifact 12.4 MB
#> │ └─ ⚠ Done 0.00s
#> ├─ ▶ Test
#> │ ├─ ℹ running 312 tests
#> │ ├─ ✖ 2 failures in test-parser.R
#> │ └─ ✖ Done 0.00s
#> └─ ✔ Done 0.01s
writeLines(readLines(log_path))
#> > Pipeline
#> |- > Build
#> | |- i compiling 84 source files
#> | |- ! 3 deprecation warnings
#> | |- + artifact 12.4 MB
#> | |- ! Done 0.00s
#> |- > Test
#> | |- i running 312 tests
#> | |- x 2 failures in test-parser.R
#> | |- x Done 0.00s
#> |- + Done 0.01s
logtree_sink_remove(h)Structured NDJSON
For anything that ends up in an aggregator rather than a terminal. One JSON record per line, with the fields a query needs.
Features shown: output sinks, verbosity
json_path <- tempfile(fileext = ".ndjson")
h <- logtree_sink_file(json_path, format = "json", threshold = "debug")
logtree_reset()
job <- function() {
log_step("Sync accounts")
log_debug("page size 500")
log_info("1,204 accounts fetched")
log_warn("12 accounts missing an email")
}
with_logging(job(), summary = FALSE)
#> ▶ Sync accounts
#> ├─ ℹ 1,204 accounts fetched
#> ├─ ⚠ 12 accounts missing an email
#> └─ ⚠ Done 0.00s
writeLines(readLines(json_path))
#> {"ts":"2026-08-11T14:29:02.479+0000","run_id":"20260811T142902.479-7653-17","level":"open","id":1,"parent_id":0,"depth":1,"label":"Sync accounts","elapsed":null,"status":"step","fn":null,"file":null,"line":null}
#> {"ts":"2026-08-11T14:29:02.480+0000","run_id":"20260811T142902.479-7653-17","level":"leaf","id":2,"parent_id":1,"depth":1,"label":"page size 500","elapsed":null,"status":"debug","fn":null,"file":null,"line":null}
#> {"ts":"2026-08-11T14:29:02.481+0000","run_id":"20260811T142902.479-7653-17","level":"leaf","id":3,"parent_id":1,"depth":1,"label":"1,204 accounts fetched","elapsed":null,"status":"info","fn":null,"file":null,"line":null}
#> {"ts":"2026-08-11T14:29:02.481+0000","run_id":"20260811T142902.479-7653-17","level":"leaf","id":4,"parent_id":1,"depth":1,"label":"12 accounts missing an email","elapsed":null,"status":"warning","fn":null,"file":null,"line":null}
#> {"ts":"2026-08-11T14:29:02.482+0000","run_id":"20260811T142902.479-7653-17","level":"close","id":1,"parent_id":0,"depth":1,"label":"Sync accounts","elapsed":0.003,"status":"warning","fn":null,"file":null,"line":null}
logtree_sink_remove(h)The console above shows no debug line – the global threshold is still
"info" – but the file has it, because this sink pinned
threshold = "debug" for itself. That is the point of
per-sink thresholds: a verbose log file does not force a verbose
terminal.
Every record carries a run_id, so one run’s lines can be
picked out of a file that many runs have appended to, and
ts is ISO-8601 to the millisecond rather than a bare epoch
number:
records <- lapply(readLines(json_path), jsonlite::fromJSON)
str(records[[3]])
#> List of 12
#> $ ts : chr "2026-08-11T14:29:02.481+0000"
#> $ run_id : chr "20260811T142902.479-7653-17"
#> $ level : chr "leaf"
#> $ id : int 3
#> $ parent_id: int 1
#> $ depth : int 1
#> $ label : chr "1,204 accounts fetched"
#> $ elapsed : NULL
#> $ status : chr "info"
#> $ fn : NULL
#> $ file : NULL
#> $ line : NULLAsserting in tests
If your package logs with logtree, test the events, not the rendered output. Pattern-matching glyphs and connectors tests logtree’s renderer; the memory sink tests your code.
Features shown: testing your logging
logtree_reset()
h <- logtree_sink_memory()
import_all()
#> ▶ Import datasets
#> ├─ ▣ sales
#> │ ├─ ▶ 2023.csv
#> │ │ ├─ ℹ 18,422 rows read
#> │ │ ├─ ✔ merged
#> │ │ └─ ✔ Done 0.00s
#> │ ├─ ▶ 2024.csv
#> │ │ ├─ ℹ 24,318 rows read
#> │ │ ├─ ✔ merged
#> │ │ └─ ✔ Done 0.00s
#> │ └─ ✔ Done 0.01s
#> ├─ ▣ returns
#> │ ├─ ▶ 2024.csv
#> │ │ ├─ ℹ 1,204 rows read
#> │ │ ├─ ✖ schema mismatch, skipped
#> │ │ └─ ✖ Done 0.00s
#> │ ├─ ▶ 2025.csv
#> │ │ ├─ ℹ 318 rows read
#> │ │ ├─ ✔ merged
#> │ │ └─ ✔ Done 0.00s
#> │ └─ ✖ Done 0.00s
#> ├─ ▣ sales
#> │ ├─ ▶ 2025.csv
#> │ │ ├─ ℹ 9,871 rows read
#> │ │ ├─ ✔ merged
#> │ │ └─ ✔ Done 0.00s
#> │ └─ ✔ Done 0.00s
#> └─ ✔ Done 0.01s
events <- logtree_sink_memory_events(h)
events[, c("level", "depth", "label", "status")]
#> level depth label status
#> 1 open 1 Import datasets step
#> 2 group 2 sales group
#> 3 open 3 2023.csv step
#> 4 leaf 3 18,422 rows read info
#> 5 leaf 3 merged success
#> 6 close 3 2023.csv success
#> 7 open 3 2024.csv step
#> 8 leaf 3 24,318 rows read info
#> 9 leaf 3 merged success
#> 10 close 3 2024.csv success
#> 11 group_close 2 sales success
#> 12 group 2 returns group
#> 13 open 3 2024.csv step
#> 14 leaf 3 1,204 rows read info
#> 15 leaf 3 schema mismatch, skipped error
#> 16 close 3 2024.csv error
#> 17 open 3 2025.csv step
#> 18 leaf 3 318 rows read info
#> 19 leaf 3 merged success
#> 20 close 3 2025.csv success
#> 21 group_close 2 returns error
#> 22 group 2 sales group
#> 23 open 3 2025.csv step
#> 24 leaf 3 9,871 rows read info
#> 25 leaf 3 merged success
#> 26 close 3 2025.csv success
#> 27 group_close 2 sales success
#> 28 close 1 Import datasets successlevel is the kind of event – "open",
"leaf", "close", "group",
"group_close" – and status is its outcome.
From there the assertions are ordinary data frame work. Inside
testthat that looks like:
test_that("a schema mismatch is logged as an error under its own file", {
logtree_reset()
h <- logtree_sink_memory()
withr::defer(logtree_sink_remove(h))
import_all()
events <- logtree_sink_memory_events(h)
errors <- events[events$level == "leaf" & events$status == "error", ]
expect_equal(nrow(errors), 1L)
expect_match(errors$label, "schema mismatch")
})
# what that assertion is looking at
subset(events, level == "leaf" & status == "error",
select = c("depth", "label"))
#> depth label
#> 15 3 schema mismatch, skipped
logtree_sink_remove(h)The buffer is capped (max = 1000 by default), so a
runaway loop cannot eat the session’s memory.
Bridging logger
An existing codebase that already calls logger, rendered as a logtree tree without touching any of the call sites.
Features shown: logger integration
logtree_reset()
logtree_threshold("debug")
ns <- "my_app"
logtree_logger(namespace = ns)
fetch_accounts <- function() {
log_step("Fetch accounts")
logger::log_debug("page size 500", namespace = ns)
logger::log_info("1,204 accounts fetched", namespace = ns)
logger::log_warn("12 accounts missing an email", namespace = ns)
}
sync <- function() {
log_step("Sync")
fetch_accounts()
logger::log_success("sync complete", namespace = ns)
}
with_logging(sync())
#> ▶ Sync
#> ├─ ▶ Fetch accounts
#> │ ├─ ⚙ page size 500
#> │ ├─ ℹ 1,204 accounts fetched
#> │ ├─ ⚠ 12 accounts missing an email
#> │ └─ ⚠ Done 0.00s
#> ├─ ✔ sync complete
#> └─ ✔ Done 0.01s
#> ✔ Run complete in 0.01s
logtree_summary()
#>
#> ── Summary: 1 warning ──────────────────────────────────────────────────────────
#> ⚠ Sync › Fetch accounts › 12 accounts missing an email
logtree_threshold("info")The logger::log_warn() call elevates its enclosing
logtree step exactly as log_warn() would, and reaches the
digest the same way – once bridged, a logger call is an ordinary
leaf.
