Skip to contents

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 ETLTransform112 rows missing fx rate, carried forward

The 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.1Apply migration 0042table lock held 800ms
#>  Release v2.1Apply migration 0042constraint violation on users.email

Backup 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 datasetsreturns2024.csvschema mismatch, skipped

Two 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 reportConnect to databaseprimary 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     : NULL

Asserting 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 success

level 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 ──────────────────────────────────────────────────────────
#>  SyncFetch accounts12 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.