How you see what the system is doing. Two primitives: a block-based lazy logger for humans reading the output, and a stack-based span tracer for programs processing the run afterward. Both ride in through include Wanderland::Boundary — any boundary has access to them with no extra setup.
Log methods take blocks, not strings. The block runs only if the log level is enabled, so expensive serialization stays free when the level filters it out.
class MyBoundary
include Wanderland::Boundary
boundary :my_boundary, capabilities: [:transform]
def call(input)
debug { "Expensive #{JSON.generate(large_object)} only runs at debug" }
info { "Processing #{input['params']['slug']}" }
warn { "Unexpected shape: #{input['params'].inspect}" }
error { "Failed: #{last_exception.message}" }
# ... boundary work ...
end
end
Output format: [timestamp] SEVERITY [class_name] message. The class name comes from self.class.name on the including class.
[2026-04-22 19:55:02] INFO [MySite::Boundaries::MyBoundary] Processing sprout-api
Configured once at boot:
Wanderland.configure_logging(
level: :info, # :debug, :info, :warn, :error, :fatal
output: "/var/log/wanderland/", # file, dir, :stdout, :stderr, or IO
name: "dark-lantern" # filename stem when output is a dir
)
Spans nest via a stack. The outermost is the root; nested trace calls inside it become children. When the root finishes, the complete tree is available for serialization or rendering.
include Wanderland::Tracing
trace("boundary:route-match", meta: { method: "GET", path: path }) do
record(:matched, true)
record(:pattern, "/node/:slug")
trace("sub-operation:compile-pattern") do
record(:param_count, 1)
end
end
Structure of a completed span:
Span.new(name:, parent:, meta:)
.id UUID
.name "boundary:route-match"
.parent parent Span or nil
.children Array[Span]
.records key/value hash set via record(k, v)
.meta metadata hash passed at span creation
.started_at Time
.finished_at Time
.duration Float seconds
.to_h nested hash, JSON-ready
The tracer keeps its span stack in Thread.current[:wanderland_trace_stack], so Puma workers don't step on each other. Completed root spans collect into a mutex-protected array available from Wanderland.tracer.completed.
Pass context: to trace and the tracer snapshots the context hash before the block runs, then records the full context and the newly-added keys afterward:
trace("stage:build", context: pipeline_context) do
pipeline_context.merge(image_tag: "my-image:abc123")
end
# span.records[:context] = full context after
# span.records[:added] = [:image_tag]
Useful for pipeline stages that mutate shared state — the span carries the diff as part of its record payload.
Boot-time conditions that don't warrant halting — a missing boundary directory, a file that fails to require, a misconfigured mount — record into a process-wide cache instead of vanishing silently. /healthcheck reads the cache and flips status to degraded when any errors are present.
# Recording pattern — a boundary catches a recoverable condition
# and keeps going, leaving a forensic trail:
unless File.directory?(dir)
Wanderland::Diagnostics.record(
:warning, "boot_load_boundaries",
"boundary_path does not exist",
dir: dir
)
return { "loaded" => [], "dir" => dir, "exists" => false }
end
Dir.glob(File.join(dir, "**/*.rb")).sort.each do |f|
begin
require f
rescue ScriptError, StandardError => e
Wanderland::Diagnostics.record(
:error, "boot_load_boundaries",
"failed to load #{f}: #{e.class}: #{e.message}",
file: f, error_class: e.class.name
)
end
end
An entry carries severity (error / warning), source (the boundary name), message, at, plus any extra keyword arguments. Reads are thread-safe and return duped copies, so callers can iterate without holding the lock. See the Diagnostics leaf for the full module and /healthcheck payload shape.
tracer = Wanderland.tracer
tracer.trace(name, meta: {}, context: nil, &block) # open span (block auto-closes)
tracer.record(key, value) # record on current span
tracer.current_span # top of stack
tracer.current_address # "parent/child/grandchild"
tracer.active? # any open spans?
tracer.completed # all completed root spans
tracer.last_completed # most recent root span
tracer.write(output_dir) # serialize last root to JSON file
tracer.reset! # clear (for testing)
Every boundary execution already opens a span via the Boundary.execute wrapper. Your code calling trace("sub-operation", …) inside call nests under that.
Log with blocks. info { "slug=#{slug}" } avoids interpolating the string when info is filtered out. info("slug=#{slug}") does the interpolation regardless.
Don't log what the trace already captures. The tracer records every crossing, every input/output, every record(k, v) call. Logs are for narrative output a human tails; traces are for machines replaying the run. If you find yourself logging structured data, record it on the span instead.
Name spans by layer, not by function. trace("boundary:my_thing"), trace("stage:build"), trace("external:http:github"). The prefix tells future-you what kind of operation it was; the suffix narrows. The trace panel groups on the prefix.
Let Wanderland::Boundary bring the mixins in. include Wanderland::Boundary pulls in Wanderland::Logging and Wanderland::Tracing, so debug/info/warn/error and trace/record are all instance methods on every boundary. Don't include them separately.
Thread-local stacks mean don't cross threads. If a boundary spawns a thread, that thread has no tracer stack. Pass data out of bounds the old-fashioned way or arrange for the child thread to establish its own root span.
The deeper nodes for each topic in this category:
/healthcheck surfaces the accumulated entries and flips status: degraded when errors are present.X-Wanderland-Verify: <scenario-name> or : route runs the actual response through ShapeMatcher against scenarios declared under scenarios.<route> in config. Halts 422 on mismatch, names the failing scenarios and ShapeMatcher failures. Config supports inline scenarios and !Fixture-loaded external files.scenarios: block, synthesises a request per scenario with the verify header set, and reports pass/fail with an exit code.Wanderland::Scenario.rspec_describe!(runtime) emits one rspec example per scenario using the same Scenario#verify(runtime:) primitive that powers the live probe and --type test. One config, one comparison, three lenses.wanderland.dev