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.

Logging from a Wasm guest via stdio or a host import Writing JSON lines to stdout or stderr works in every WASI host but gives the host little control and little context. A host logging import lets the host format, enrich, rate-limit and route entries, and attach context the guest cannot forge. JSON lines on stdio works in every WASI host guest formats everything host sees opaque text portable default host logging import host adds request and tenant ids rate limits per guest routes into the host's logger best when you own the host

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.

A guest log entry through a host import Guest code logs with ordinary macros. A small subscriber in the guest converts each event into a call to the host's log import. The host checks the guest's log budget, adds request, tenant and plugin identity, caps field sizes, and writes a JSON line through its own logger. info!(order_id, …) guest code guest subscriber → log.emit(…) host: budget check rate limit per instance host: enrich request, tenant, plugin ids JSON line into the log pipeline

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.

Cost per log entry from a Wasm guest Microseconds per log entry for a guest writing JSON to stderr through WASI and for a guest calling a host logging import, measured in wasmtime. microseconds per entry JSON on WASI stderr 3 µs host logging import 1.5 µs

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.

← Back to Observability & Error Reporting