Skip to contents

logtree renders nested process execution as a live, colored tree in the console: tree connectors, status glyphs, and elapsed time per step. Nesting depth is tracked via frame-exit handlers, so it never desynchronizes, even when a step errors partway through.

Basic nesting

log_step() opens a step and automatically closes it when the calling function returns – normally, via an early return(), or because an error propagated through it. Nesting composes for free: a step opened inside another step is automatically indented one level deeper, with no coordination needed between the two functions.

logtree_reset()

load_config <- function() {
  log_step("Load config")
  log_info("Reading config.yml")
  log_success("Validated 12 parameters")
}

pipeline <- function() {
  log_step("Pipeline")
  load_config()
}

pipeline()
#>  Pipeline
#> ├─  Load config
#> │  ├─  Reading config.yml
#> │  ├─  Validated 12 parameters
#> │  └─  Done  0.00s
#> └─  Done  0.01s

Leaf lines and status elevation

log_info() and log_success() are plain leaf lines. log_warn() and log_error() additionally elevate the enclosing step’s status, so its close line renders the elevated glyph even though the function that opened it returns normally.

logtree_reset()

fetch_articles <- function() {
  log_step("Fetching articles")
  log_info("Connecting to API")
  log_warn("Retry 1/3 due to timeout")
  log_success("Fetched 1,204 articles")
}

fetch_articles()
#>  Fetching articles
#> ├─  Connecting to API
#> ├─  Retry 1/3 due to timeout
#> ├─  Fetched 1,204 articles
#> └─  Done  0.00s

Handling uncaught errors with with_logging()

If a step’s code actually throws, wrap the run in with_logging(). Every step still open at the moment of the error is flagged as failed before the stack unwinds, the error is logged as a leaf line, a run summary prints, and the error is rethrown – with_logging() never silently swallows it.

logtree_reset()

run_classifier <- function() {
  with_logging({
    log_step("Classifying")
    stop("model timeout after 30s")
  })
}

tryCatch(run_classifier(), error = function(e) invisible(NULL))
#>  Classifying
#> ├─  model timeout after 30s
#>  Run failed in 0.00s
#> └─  Done  0.00s

Even without with_logging(), depth tracking still unwinds correctly on an uncaught error – the step just renders as dimmed/interrupted rather than retroactively painted red, since there was no handler installed to catch the condition and flag it before the stack unwound.

logtree_reset()

risky <- function() {
  log_step("risky")
  stop("boom")
}

tryCatch(risky(), error = function(e) invisible(NULL))
#>  risky
#> └─  Done  0.00s

Grouping

log_step(label, group = value) collapses adjacent steps that share the same value under one synthetic header, instead of nesting each step on its own. Here several files belong to the same dataset, so each dataset becomes a header and its files sit underneath. The group stays open across calls with a matching value and only closes – as a sibling, with its own status aggregated from its members – once a step with a different value (or a plain ungrouped step) appears at the same level, or the enclosing step finishes. Grouping is strictly adjacency-based: the same value recurring later, after something else has closed the group, opens a fresh one rather than reusing the old header.

Pass a bare value to use it as both the match key and the header, or c(name = value) to show a fixed name while still grouping on value.

logtree_reset()

load_file <- function(dataset, file) {
  log_step(file, group = dataset)
  log_info("Reading rows")
  log_success("Merged into dataset")
}

import_datasets <- function() {
  log_step("Import datasets")
  load_file("sales",   "2023.csv")
  load_file("sales",   "2024.csv")
  load_file("returns", "2024.csv")
}

import_datasets()
#>  Import datasets
#> ├─ ▣ sales
#> │  ├─  2023.csv
#> │  │  ├─  Reading rows
#> │  │  ├─  Merged into dataset
#> │  │  └─  Done  0.00s
#> │  ├─  2024.csv
#> │  │  ├─  Reading rows
#> │  │  ├─  Merged into dataset
#> │  │  └─  Done  0.00s
#> │  └─  Done  0.02s
#> ├─ ▣ returns
#> │  ├─  2024.csv
#> │  │  ├─  Reading rows
#> │  │  ├─  Merged into dataset
#> │  │  └─  Done  0.00s
#> │  └─  Done  0.00s
#> └─  Done  0.02s

Themes

Three built-in presets are available: "unicode" (default), "ascii" (safe for log files, CI, and non-UTF-8 terminals), and "emoji". Every glyph is overridable, and switching themes never breaks column alignment, since each glyph declares its own rendered width rather than having that width measured from the string.

logtree_theme("ascii")
pipeline()
#> > Pipeline
#> |- > Load config
#> |  |- i Reading config.yml
#> |  |- + Validated 12 parameters
#> |  |- + Done  0.00s
#> |- + Done  0.00s

logtree_theme("unicode")

Individual glyph slots can be overridden with overrides – a named list keyed by slot, each element holding only the fields to change (everything else is kept from the active theme):

logtree_theme("unicode", overrides = list(
  success = list(glyph = "*", color = c("green", "bold")),
  group   = list(bracket = TRUE)
))
pipeline()
#>  Pipeline
#> ├─  Load config
#> │  ├─  Reading config.yml
#> │  ├─ * Validated 12 parameters
#> │  └─ * Done  0.00s
#> └─ * Done  0.00s
logtree_theme("unicode")

Accepted slots (valid names in an overrides list):

Slot Applies to Fields it accepts
step open / running step glyph glyph, width, color
info log_info() leaf glyph, width, color
debug log_debug() leaf glyph, width, color
success success glyph (clean close, log_success()) glyph, width, color
warning log_warn() / elevated step glyph glyph, width, color
error log_error() / elevated step glyph glyph, width, color
interrupted abnormal-exit (dimmed) glyph glyph, width, color
group group header marker glyph, color, bracket
branch child connector (├─) glyph, color
corner close-line connector (└─) glyph, color
pipe vertical rail () glyph, color

Accepted fields (valid names inside a slot):

Field Type Accepted values
glyph character(1) Any string, including "".
width integer(1) Rendered display width of the glyph (1 normal, 2 emoji/wide). Sets column alignment; status slots only.
color character / NULL One or more cli styles, or NULL. Named ("red", "cyan", …), bright ("br_red"), backgrounds ("bg_blue"), styles ("bold", "dim", "italic"), or hex ("#ff8800"). A vector combines them, e.g. c("red", "bold").
bracket logical(1) group slot only. TRUE wraps the header name in < >.

Output sinks

The console sink is always on. logtree_sink_file() adds a plain-text or NDJSON file sink; every logged event fans out to all active sinks simultaneously.

log_path <- tempfile(fileext = ".log")
logtree_sink_file(log_path, format = "text")

logtree_reset()
pipeline()
#>  Pipeline
#> ├─  Load config
#> │  ├─  Reading config.yml
#> │  ├─  Validated 12 parameters
#> │  └─  Done  0.00s
#> └─  Done  0.00s

writeLines(readLines(log_path))
#> > Pipeline
#> |- > Load config
#> |  |- i Reading config.yml
#> |  |- + Validated 12 parameters
#> |  |- + Done  0.00s
#> |- + Done  0.00s

Verbosity

logtree_threshold() sets the minimum leaf-line level to render ("debug", "info", "warn", "error"). Step open/close lines always render regardless of verbosity, since hiding them would break the tree structure; a suppressed log_warn()/log_error() still elevates the enclosing step’s close glyph.

logtree_threshold("warn")
fetch_articles()
#>  Fetching articles
#> ├─  Retry 1/3 due to timeout
#> └─  Done  0.00s
logtree_threshold("info")

log_debug() is the most verbose level, for fine-grained diagnostic detail. It’s hidden by default (verbosity is "info") and only shown when logtree_threshold("debug") is called. Like log_info() and log_success(), it does not elevate the enclosing step’s status.

logtree_reset()

fetch_verbose <- function() {
  log_step("Fetching")
  log_debug("cache miss for key user:42")
  log_info("connecting to API")
  log_debug("request took 84ms")
  log_success("fetched 12 records")
}

# At default verbosity, debug lines are hidden
fetch_verbose()
#>  Fetching
#> ├─  connecting to API
#> ├─  fetched 12 records
#> └─  Done  0.00s

# Raise verbosity to show debug lines
logtree_threshold("debug")
logtree_reset()
fetch_verbose()
#>  Fetching
#> ├─  cache miss for key user:42
#> ├─  connecting to API
#> ├─  request took 84ms
#> ├─  fetched 12 records
#> └─  Done  0.00s

logtree_threshold("info")

Integrating with the logger package

If your codebase already uses the CRAN logger package (https://daroczig.github.io/logger/) for logging, you can route those calls through logtree without rewriting them. Call logtree_logger() once near the top of your script: it registers logtree’s custom layout, pairs it with logger::appender_void (a no-op appender) so logtree does the rendering, and opens logger’s own threshold so that logtree_threshold() becomes the single gate on what is shown.

logtree_reset()
logtree_threshold("debug")  # the only gate, now that logtree_logger() opened logger's

ns <- "my_app"
logtree_logger(namespace = ns)

process_data <- function() {
  log_step("Processing data")
  logger::log_info("reading input file", namespace = ns)
  logger::log_debug("parsed 5,000 rows", namespace = ns)
  logger::log_success("transformation complete", namespace = ns)
}

process_data()
#>  Processing data
#> ├─  reading input file
#> ├─  parsed 5,000 rows
#> ├─  transformation complete
#> └─  Done  0.00s

logger severities map onto logtree leaf levels: FATAL/ERROR become [log_error()], WARN becomes [log_warn()], SUCCESS becomes [log_success()], INFO becomes [log_info()], and DEBUG/TRACE both become [log_debug()] (logger has two debug-ish tiers, logtree has one). Note that logger’s own log_threshold() and logtree_threshold() are two independent gates applied on top of each other – both apply simultaneously, which is intentional.