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()andlog_error()callelevate_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/\Uescapes only. - No external dependencies beyond:
cli,rlang,withr(core stack manipulation). - Examples and debug scripts write to
tempdir(), never the working directory.
