Skip to contents

This article explains how logtree works internally and the reasoning behind its architecture.

The problem

Traditional logging often shows flat message streams or fixed indentation. Nested process execution — where steps call other steps — becomes hard to visualize correctly. If nesting depth is tied to step counts or manual indentation, it can get out of sync when a step errors or exits early, leaving the log confusing.

logtree solves this by tying nesting depth to function frames: a step opened inside another step is automatically indented exactly one level deeper, and the close happens when that function returns — whether normally, via early return(), or because an error unwound through it. No manual coordination needed.

State & step lifecycle

All state lives in a single package-private environment (the):

  • stack — list of currently-open steps and groups
  • next_id — counter for unique step IDs

When log_step() is called, it pushes an entry to the stack and registers its close via withr::defer(..., envir = rlang::caller_env(), priority = "first")in the caller’s frame, not inside log_step() itself. This is the key: the close fires when the calling function’s frame exits, whether by normal return, early return(), or an uncaught error unwinding through it.

Result: a step can never leak and desync indentation.

Status elevation

Step status follows a severity hierarchy: running < success < warning < error.

Tier 1 (always on):

  • log_warn() and log_error() call elevate_current_step(), which bumps the nearest open step’s status without the step itself throwing.

Tier 2 (opt-in via with_logging()):

  • Installs a withCallingHandlers(error = ...) that marks every currently open step "error" and logs the condition message as a leaf before the stack unwinds, then rethrows.
  • with_logging() never swallows errors.

If a step’s frame exits abnormally with no Tier-2 handler, finalize_step() marks it "interrupted" (dimmed glyph) rather than showing false success.

Grouping

log_step(label, group = c(name = value)) collapses adjacent steps sharing the same value under one synthetic kind = "group" stack entry (a header-only parent).

  • open_or_reuse_group() reuses the top-of-stack group if the incoming (name, value) matches.
  • settle_groups() pops any lingering group that doesn’t match before pushing a new entry.
  • Grouping is strictly adjacency-based: the same value recurring non-adjacently opens a fresh group.
  • A plain leaf or ungrouped step at the group’s level closes the group as a sibling.

Rendering

Four pure functions compute output:

  • format_open() — opening line of a step
  • format_close() — closing line (with elapsed time)
  • format_leaf() — a leaf message (info, success, warn, error)
  • format_group_header() — group header line

Each takes (entry, theme, color) and returns a string. The same logic backs every sink (console, file, JSON).

Rendering follows a corner-on-close, zero-buffer strategy:

  • Every child line uses the branch connector (├─)
  • The corner connector (└─) appears only on a step’s own close line
  • This is necessary because logtree is a live streaming logger that can’t know in advance if a line is the last sibling

Theming

Three built-in presets (glyphs_unicode, glyphs_ascii, glyphs_emoji) are named lists keyed by status/connector. Each glyph entry declares its own width explicitly — measured sizes (nchar(), ansi_nchar()) can’t reliably size emoji cells.

logtree_theme() either swaps the whole preset or merges a named list of per-key overrides via utils::modifyList().

Important: non-ASCII glyphs must be written as \u / \U escapes, never literal characters, for CRAN portability.

Appenders & sinks

emit() fans every event out to all registered the$sinks. The console sink is always on; logtree_sink_file() adds a text or JSON sink.

Event kinds:

  • open — step opened
  • close — step closed
  • group — group header
  • leaf — message (info, success, warn, error, debug)

JSON format is hand-rolled (no jsonlite dependency) because the event shape is fixed and small.

CRAN compliance

logtree targets R CMD check --as-cran with 0 errors/warnings/notes. Key constraints:

  • All exported and side-effecting functions return invisible(...) — no output on package load.
  • Non-ASCII glyphs use \u / \U escapes only.
  • No external dependencies beyond: cli, rlang, withr (core stack manipulation).
  • Examples and debug scripts write to tempdir(), never the working directory.