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
The trace column
The trace slot renders a call site —
fn() file.R:line — inline after the message rather than in
a column of its own. That is a deliberate choice: a prefix column would
have to be fixed-width (or the tree shears), would have to be added into
every cols argument compose_line() receives,
and would need blanking on wrapped continuation rows. Appending to the
message instead leaves all of the layout arithmetic untouched and makes
wrapping account for the trace for free. The cost is that a long line
can wrap mid-trace, which is acceptable.
Capture is gated: with the slot off — the default in every preset —
no frame walk and no srcref lookup happens at all. {file}
and {line} depend on keep.source, so they are
routinely absent; a template run whose placeholders are all unavailable
is dropped whole rather than rendering NA.
Two condition-handling paths get their call site handed to them
rather than walking for it, because they log from inside a frame of
their own: with_logging()’s error handler uses
conditionCall(), and layout_logtree() uses
logger’s .topcall. A frame walk in either
would name the handler or the layout instead of the user’s code.
Theming
Five built-in presets (glyphs_unicode,
glyphs_ascii, glyphs_emoji,
glyphs_minimal, glyphs_ci) 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, a list keyed by sink id so a registration can be
undone. The console sink is registered by default under the reserved id
"console"; logtree_sink_file() adds a text or
JSON sink, logtree_sink_memory() an in-memory buffer, and
logtree_sink() any function of your own. Each carries its
own verbosity threshold, defaulting to the global
logtree_threshold(), and each call is wrapped so a sink
that throws is skipped rather than allowed to break the fanout.
Event kinds:
-
open— step opened -
close— step closed -
group— group header -
group_close— a group’s own corner/close line, rendered through the sameformat_close()as a step’s -
leaf— message (info, success, warn, error, debug)
JSON format is hand-rolled (no jsonlite dependency)
because the event shape is fixed and small. Its fn /
file / line fields carry the call site
whenever one was captured, independent of whether the console column is
on — a structured log wants every field it can get.
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.
