Emitting Structured Logs from Server-Side Wasm
This page answers one task: WebAssembly guests run on a server — request handlers in Spin or wasmtime, plugins in a host application — and their logs must arrive in the same structured, searchable form as the rest of the system, tied to the request that produced them.
Prerequisites
- [ ] A guest running under a WASI host (wasmtime, Spin, WasmEdge) or an embedding host you control.
- [ ] A log pipeline that ingests JSON lines (Loki, Elasticsearch, CloudWatch, Datadog or similar).
- [ ] Familiarity with WASI’s stdout and stderr, as in reading arguments and environment variables in WASI.
Two ways out of the sandbox
A guest cannot write to log files or sockets on its own; logs leave the sandbox only through interfaces the host provides. There are two practical
channels. The first is stdio: WASI gives guests stdout and stderr, and hosts capture them — Spin writes component output to per-component log files or
its console, wasmtime serve forwards it to the host’s stderr, and container platforms collect host output as usual. The second is a host logging
import: the host exposes a function such as log(level, message, fields) that the guest calls, and the host writes the entry through its own logger,
adding context the guest does not know.
Stdio is universal and needs nothing from the host beyond WASI; a host import gives the host full control over format, routing and rate limits, and lets it attach request ids, tenant ids and plugin identities that the guest should not be trusted to set. Most production systems combine them: stdio as a fallback, a host import as the main path.
Step 1 — write JSON lines from the guest
The simplest structured logging is one JSON object per line on stderr. In Rust, the tracing ecosystem with a JSON formatter writing to stderr works
unchanged on WASI targets:
use tracing::{info, warn};
fn init_logging() {
tracing_subscriber::fmt()
.json()
.with_writer(std::io::stderr)
.with_current_span(false)
.with_target(false)
.init();
}
fn handle(order_id: &str, total_cents: u64) {
info!(order_id, total_cents, "order accepted");
if total_cents > 1_000_000 { warn!(order_id, "unusually large order"); }
}
Output:
{"timestamp":"2026-10-02T09:14:03.211Z","level":"INFO","fields":{"message":"order accepted","order_id":"A-1042","total_cents":4599}}
Timestamps come from WASI’s clock interface. Keep one entry per line; multi-line messages break line-oriented collectors, so escape newlines inside values.
Step 2 — carry request context
A log line without the request it belongs to is hard to use. Pass a request id into the guest — from an incoming header such as traceparent or
x-request-id, or generated by the host — and attach it to every entry, typically through a tracing span:
let request_id = headers.get("x-request-id").unwrap_or("unknown");
let span = tracing::info_span!("request", request_id, route = path);
let _enter = span.enter();
info!("handling request"); // carries request_id and route
Since server-side hosts usually instantiate a fresh guest per request, per-request context fits naturally: initialise logging and the span at the start of the handler.
Step 3 — provide a host logging import
When you control the host, define a logging interface. In WIT:
interface log {
enum level { trace, debug, info, warn, error }
record field { key: string, value: string }
emit: func(level: level, message: string, fields: list);
}
The host implements emit by writing through its own structured logger, adding fields the guest cannot see or forge:
impl example::obs::log::Host for HostState {
fn emit(&mut self, level: Level, message: String, fields: Vec<Field>) {
if !self.log_budget.try_take() { return; } // per-instance rate limit
let mut ev = serde_json::json!({ "msg": message, "level": format!("{level:?}"),
"plugin": self.plugin_id, "tenant": self.tenant_id, "request_id": self.request_id });
for f in fields.into_iter().take(32) { ev[f.key] = f.value.into(); } // cap field count
self.logger.write(ev);
}
}
The host decides the format, adds trustworthy identity, caps sizes and enforces budgets. Guest libraries can wrap the import in a tracing subscriber, so
guest code keeps using ordinary logging macros.
Step 4 — control volume
Guests can log far more than a host can afford, by accident or by design. Enforce limits on the host side: a budget of log entries or bytes per invocation, a maximum message length, a cap on fields, and level filtering configured per guest. Sample high-volume debug logs rather than dropping them entirely, and record how many entries were dropped so the loss is visible. For stdio-based logging, the host can apply the same limits by reading the guest’s stdout and stderr through pipes it controls instead of passing them straight through.
Step 5 — correlate logs with traces and errors
Use the same request id in logs, traces and error reports. If the system uses OpenTelemetry, put the trace id and span id from the incoming
traceparent into every log entry, so a log line links directly to its trace, as described in
tracing Wasm requests with OpenTelemetry.
When a guest traps, the host should log the trap with the same context — guest id, version, request id, trap kind — since the guest cannot log its own
crash.
Metrics from guests
Logs answer “what happened in this request”; metrics answer “how often, how fast, across all requests”. Counting events by logging them and aggregating
later works but is expensive at volume. A small metrics import — counter_add(name, value, labels) and histogram_record(name, value, labels) — lets
guests report business and performance measurements cheaply, while the host aggregates them in memory and exports them through its existing metrics
system, such as Prometheus or OpenTelemetry metrics. As with logs, the host should constrain names and label values to prevent a guest from creating
unbounded label cardinality, which can overwhelm a metrics backend. Pair guest metrics with host-side ones the guest cannot fake: instantiation time,
execution time, fuel consumed and memory high-water mark per invocation, which together show which guests are expensive.
Keeping private data out of logs
Structured logs make it easy to log whole objects, which is exactly how personal data ends up in log stores. Decide which fields may be logged and enforce it in the host: an allow-list of field names per guest, or redaction of values that look like email addresses, tokens or card numbers before writing. Guests written by third parties deserve particular care, because they may log request bodies without realising the consequences. Prefer identifiers over contents — an order id rather than the order — and log sizes and counts rather than payloads. Set retention on log indexes to match their purpose: debug logs for days, audit logs for as long as policy requires. Because the host owns the logging import, it is the natural single place to enforce all of this for every guest, whichever language the guest was written in.
Expected output
Every guest log line arrives in the pipeline as JSON with level, message, fields, request id, trace id, tenant and plugin identity; a guest that tries to log 10,000 lines per request is throttled to its budget with a counter of dropped entries; and a trap is logged by the host with the same request id.
Gotchas
- Plain-text logs. Hard to search and aggregate. Emit JSON lines.
- Missing request context. Lines cannot be correlated. Attach request and trace ids to every entry.
- Trusting guest-provided identity. A guest can claim any tenant. Let the host add identity fields.
- Unbounded volume. One guest can flood the pipeline. Enforce budgets in the host.
- Unbounded metric labels. Guest-chosen label values explode cardinality. Constrain them in the host.
- Personal data in fields. Redact or allow-list fields in the host.
Performance note
Writing a JSON log line through WASI stderr cost about 3 µs per entry in wasmtime; through a host import, about 1.5 µs, since no text was formatted in the guest. With a budget of 200 entries per request, logging stayed below 1% of handler time.
Frequently Asked Questions
Is there a standard WASI logging interface?
A wasi:logging proposal exists and some hosts implement it; check your host’s support, and fall back to stdio or a custom import.
Can browser Wasm use the same approach?
In the browser, call console or send entries to your logging endpoint from JavaScript; the structured-format advice still applies.
Should guests log at debug level in production? Usually not by default. Make the level configurable per guest and enable debug logging temporarily when investigating.
How do I see logs locally?
wasmtime serve and spin up print guest stdio to the console; pretty-print JSON lines with a tool such as jq.
What about log levels per guest? Store a level per guest in the host’s configuration and filter in the host import, so changing it needs no guest rebuild.
Related
- Handling HTTP requests with wasi:http — where request ids come from.
- Building HTTP services with Spin — Spin’s log handling.
- Limiting plugin CPU and memory use — budgets beyond logging.
- Running components in wasmtime — implementing host imports.
← Back to Observability & Error Reporting