A privacy-first structured observability facade for Rust. Zero dependencies,
no_std, no allocation, and no behavior at all until an application installs a
runtime.
$ cargo tree -p logwise
logwise v0.7.0That is the whole dependency tree, and a CI gate keeps it that way.
Instrumentation has a cost problem and a trust problem, and most logging crates solve neither.
The cost problem. Instrumenting a library normally taxes everyone who
compiles it, whether or not they ever read a log line: a dependency tree, an
allocator, formatting work for fields nobody asked for. The logwise facade
depends on nothing, builds without std or alloc, and checks a cached
per-call-site interest mask before evaluating any field expression. With no
runtime installed, every operation is a non-allocating no-op. With a runtime
installed, a field is evaluated only if some destination is actually
authorized to receive it — once, even when three sinks want it, and never when
none do.
The trust problem. Conventional logging treats privacy as a deployment
concern: whatever you log goes wherever your sinks go, and keeping user data
out of a remote crash reporter is a matter of discipline. logwise makes
privacy a per-field schema axis with three tiers — support (safe to leave
the machine), local (retained locally only), and secret (never retained at
all) — and gives sinks capabilities, not promises. A remote sink is handed a
projection that support-safe fields are copied into; local-only and secret
values are not withheld by convention, they are never materialized into the
view that remote code receives.
The vocabulary problem. debug-versus-info says nothing about why an
event exists. logwise separates class (operational, diagnostic, forensic,
performance, metric) from severity, separates throwaway printf-debugging
from durable events with stable names and schemas, and makes timing questions
precise: wall time, active poll time, and wake latency are different
measurements with different macros.
The 0.7 series is a substantial, intentionally breaking rewrite. The public
contract described here — the facade package boundary, the call-site macros,
the privacy model, and the dispatch ABI — is the new design. The previous
implementation lives on in logwise_runtime while it is ported onto that
contract, and its legacy macros (info_sync! and friends) remain available
from there during the migration. Applications now explicitly install a runtime
instead of receiving implicit logging behavior. The API may still change.
A library depends on the facade alone. For temporary printf-style debugging, use ordinary Rust formatting:
# let task_id = 42;
logwise::log!("spawned task {task_id}");log! is a diagnostic, debug-severity, ad-hoc text event. It stays compiled
in ordinary optimized builds, inherits the active context, and is local-only —
formatting erases field boundaries, so its text can never be relabelled safe
for remote destinations. Its arguments are not evaluated unless a local
observer requests the call site.
Durable instrumentation uses a stable event name and privacy-labelled fields:
# let task_id = 42_u64;
# let parent_id = 7_u64;
logwise::event!(
"some_executor.task.spawned",
task_id = support(task_id),
parent_id = support(parent_id),
detail task_name = local("worker"),
);Unlabelled fields default to local-only. Prefixing a field with detail
keeps its expression unevaluated until an observer explicitly asks for
expensive detail.
The application picks a runtime, installs it, and registers destinations (application-side setup; the runtime is a separate package):
use std::sync::Arc;
use logwise_runtime::{ConsoleSink, DetailLevel, Filter};
fn main() {
let runtime = logwise_runtime::init().expect("install logwise runtime");
runtime.add_local_sink(Arc::new(ConsoleSink), Filter::new(), DetailLevel::Full);
logwise::event!("app.started", pid = support(std::process::id()));
}
Each registered view narrows what it receives by hierarchical domain, stable
event name, class, minimum severity, or context lineage — and its entry point
(add_remote_sink, add_local_sink, add_ephemeral_sink) fixes the privacy
tier it is even capable of observing.
| Package | Role |
|---|---|
logwise |
The zero-dependency, no_std, no-alloc facade. Everything a library needs. |
logwise_runtime |
The standard runtime: dispatch, context storage, clocks, filtering, projection, sinks, the flight recorder. Hosts the legacy 0.6 implementation while it is ported to the facade contract. |
logwise_runtime/logwise_runtime_proc |
Temporary legacy procedural macros; removed when the port completes. |
logwise_runtime_wasm |
The structured logwise_v1 wasm host transport, without depending on any wasm binding crate. |
logwise_compat_log |
Optional bridge importing log records as quarantined local-only events. |
logwise_compat_tracing |
Optional tracing layer importing spans, events, and causality the same way. |
logwise_integration_tests |
Cross-package acceptance tests, kept above the facade so its own graph stays empty. |
The facade may depend on nothing; runtimes may depend on the facade; no
runtime may depend on an executor. scripts/facade_boundary enforces the
first rule in CI, along with a detached no_std, no-alloc consumer fixture
proving the default build needs neither std nor alloc. Optional alloc
and std features exist for convenience layers without changing the default
graph.
The short event! form shown above defaults to operational, info-severity
metadata. An unusual durable site spells its policy explicitly:
# let task_id = 42_u64;
logwise::event!(
class: forensic,
severity: debug,
name: "some_executor.task.polled",
task_id = support(task_id),
);forensic! is shorthand for exactly that class/severity pair, and counter!
and measurement! emit metric-class observations with the same field
grammar. The supported privacy labels are support, local, and secret;
primitive values and &str convert automatically, and custom types pass
ValueRef::debug(&value) or ValueRef::display(&value).
A domain override is a static value and is rarely needed — event names are already hierarchical:
# let task_id = 42_u64;
const SCHEDULER: logwise::Domain = logwise::domain!("some_executor.scheduler");
logwise::event!(
domain: SCHEDULER,
name: "some_executor.task.queued",
task_id = support(task_id),
);Static removal belongs to the crate containing the call site. Put its local
feature or target condition directly on the invocation; logwise transcribes it
as #[cfg] elimination, so values and schema strings are absent from the
binary:
# let task_id = 42_u64;
logwise::event!(
#[cfg(any())] // for example: feature = "logwise-forensic" in this crate
"some_executor.task.woken",
task_id = support(task_id),
);The boundary gate compiles a fixture to object code and greps it: excluded metadata must be gone, and ordinary optimized builds must not strip the instrumentation that was supposed to stay.
Durable context belongs to the task, not the thread that happens to poll it. An executor captures a parent when work is spawned, stores the child token in the task, and enters it only around each poll:
let parent = logwise::context::capture();
let task = logwise::context::child(parent, "some_executor.task");
// Immediately around Future::poll:
let _entered = logwise::context::enter(task);ContextToken is a fixed-size copyable value, cheap to store in every task.
The runtime stores its parent lineage and separate non-parent links
(logwise::context::link); the enter guard is deliberately not sendable and
restores the previous thread/worker-local token on drop. With no runtime
installed, capture/child/link/enter are harmless no-ops.
span! measures wall time from creation to guard drop. Performance-specific
helpers make the other timing questions explicit:
# use core::time::Duration;
let _wall = logwise::span!("some_executor.task.wall");
let _poll = logwise::active_span!("some_executor.task.poll");
let _wake = logwise::wake_latency_span!("some_executor.task.wake_latency");
let _warning = logwise::perfwarn!(
threshold: Duration::from_millis(100),
name: "some_executor.task.slow",
);The facade never reads a clock. It starts a runtime span only after interest accepts the call site, and the runtime records elapsed monotonic time — and any threshold violation — when the guard drops. The guard captures its originating context, so completion is attributed to where the work began even if the guard crosses threads or another context becomes current first.
Each static Callsite owns a generation-keyed Interest cache. Call-site
macros check that interest before constructing anything, then synchronously
pass a borrowed EventRef to the single installed dispatcher — no owned
record, no queue, no future. Interest distinguishes core/detail cost and
support-safe/local-only/secret privacy groups, so the check that guards field
evaluation is the same check that guards privacy tiers.
An application does not implement this ABI directly. Its chosen runtime installs one dispatcher and mutates filters and sinks behind that stable pointer, advancing its configuration generation whenever interest changes; a call site whose cached generation is stale simply recomputes. The interest mask at each call site is the union of every authorized view, so a field needed by three sinks is still evaluated once, and a field needed by none is not evaluated at all. Targets without pointer-width atomics deliberately remain no-runtime no-ops.
The standard runtime registers destinations through three entry points —
remote-safe, retained-local, and explicitly trusted ephemeral. All implement
the same EventSink contract, but each receives a ProjectedEvent assembled
after authorization:
- remote views contain support-safe fields only and never receive ad-hoc text;
- retained-local views contain support-safe and local-only fields;
- trusted ephemeral views may additionally inspect secret fields during the synchronous call;
- secret fields are never copied into a runtime-owned retained event.
Each view also chooses core-only or full detail and may filter by domain, event name, class, minimum severity, and context descendants.
TTL activation turns instrumentation up temporarily using the same selectors,
and answers honestly: Enabled, UnavailableTarget, NotCompiled, or
UnknownSelector. Activations retain a dynamic refinement bit in the
call-site cache, so expiry takes effect without waiting for unrelated
configuration changes. The runtime catalog reports every call site observed
in this build.
Console and in-memory sinks consume projected events synchronously. Retaining
or I/O destinations copy the projection into an OwnedProjectedEvent with an
explicit per-string truncation limit; they still never see fields excluded by
their capability.
AsyncSink puts those owned events into a bounded queue and returns from the
log call immediately. Its default overflow policy drops the newest record and
counts it; overwrite-oldest is available for history-style queues. Accepted,
dropped, overwritten, truncated, and writer-error totals are all observable —
loss is accounted for, never silent. flush() yields a future barrier;
flush_blocking() and emergency_drain() are explicit blocking operations
for shutdown paths. Normal instrumentation never creates a future or waits
for I/O.
Runtime fan-out snapshots sink handles before invoking user code. Removing a sink drops it after the configuration lock is released (sink destructors may themselves log), recursive sink logging is counted and dropped, and unwind-capable targets isolate a panicking sink so the remaining views still run.
FlightRecorder is a retained-local, core-detail sink for recent structured
history — the "what just happened" buffer you consult after a bug report.
Capacity is fixed, writes are distributed across thread/worker shards, and
neither writers nor queries wait for a contended shard.
read_since returns globally sequence-ordered records plus the next
monotonic cursor, along with cumulative drop, overwrite, and truncation
totals, per-record omissions, and the number of shards that were busy. A
partial read does not advance its cursor, so a runner or platform mirror can
retry instead of silently losing the unavailable shard. tail provides the
newest bounded view.
Local reads contain support-safe and local-only fields. Remote reads clone and project retained records down to support-safe fields and strip opaque ad-hoc messages before the caller can serialize them. Secret fields are rejected at recorder ingress even if the sink is invoked outside the standard runtime.
First-party logwise events are the portable contract. Text intercepted from
anywhere else is best-effort input with no trusted privacy declaration, so it
all lands in one quarantine lane: the unstable-schema foreign.text event,
with local-only origin and text fields. Its kind is categorically
excluded from remote sinks; callers cannot relabel imported text
support-safe.
logwise_runtime::install_panic_hook()chains a process-wide panic hook and returns the previous hook for explicit restoration.- On Unix,
NativeFdCapturecan temporarily redirect stdout or stderr — a process-global operation with documented races, not a logical-context capture. - The
foreign-nightly-rust-printfeature exposes an adapter over nightly Rust's unstableinternal_output_capture, intended for test harnesses only. Nothing in the default facade or runtime claims to intercept arbitraryprintln!calls. logwise_runtime_wasm::ingest_consoleaccepts text a host's console monkeypatch already intercepted, preserving origins such asjs.console.warn.
The optional logwise_compat_log package installs a log::Log
implementation mapping levels, targets, messages, and key-values into
origin-marked local-only records. logwise_compat_tracing::LogwiseLayer maps
tracing span parentage to logwise context tokens, follows_from to links,
and events and fields into the same quarantined lane; compose it into an
existing subscriber or call its install(). Both bridges carry thread-local
reentrancy guards, so an outbound sink that itself logs cannot create
log → logwise → log recursion.
There is intentionally no built-in outbound bridge: flattening logwise into either ecosystem would lose privacy projection, detail tiers, retention policy, and part of the causal model.
wasm32 is a first-class target, not a port. logwise_runtime_wasm encodes
first-party events as allocation-free, versioned logwise_v1 binary
envelopes preserving stable call-site metadata, typed fields and their
privacy/detail policy, context links, test/worker identity, and
sequence/drop/truncation accounting. Secret fields are defensively excluded.
Each call is a complete frame, so hosts mirror events incrementally instead
of waiting to query a guest that may be hung.
The host-abi feature imports logwise_v1.emit(ptr, len). It is opt-in
because WebAssembly imports resolve at instantiation: embedders without the
ABI leave it disabled and receive HostStatus::Unavailable; embedders that
enable it must supply the import. The complete layout and a canonical host
vector live in logwise_runtime_wasm/LOGWISE_V1.md, and every test in the
workspace runs on both native and browser targets.
scripts/check_allruns the facade boundary gate, formatting, checks, clippy, tests, and docs
across native and wasm32, including the no_std/no-alloc fixture and the
dependency-tree check.
MSRV is Rust 1.95.0, edition 2024. Licensed under MIT OR Apache-2.0.
