Skip to content

Logging — Contracts

Rust backend

Framework

The tracing ecosystem (tracing, tracing-subscriber, tracing-appender). Application events use typed posthaste-observability wrappers over tracing; subscriber setup is centralized in posthaste-server.

Subscriber stack

A layered subscriber composed of:

Layer Sink Format When
Stderr terminal Human-readable, ANSI colors Always (dev and prod)
File appender <data_dir>/logs/posthaste.YYYY-MM-DD JSON lines (one object per event) Always
Env filter Controls which levels reach each layer
Reload filter Enables runtime log-level changes

Log levels

Level Usage
ERROR Unrecoverable failures, broken invariants
WARN Degraded operation — retries, fallbacks, missing optional data
INFO Lifecycle milestones — server start, sync complete, account connected, config loaded
DEBUG Per-request details, JMAP method calls, state token changes
TRACE Wire-level detail — raw payloads, SQL statements (opt-in)

Default level: INFO for production, DEBUG for development (overridable via RUST_LOG).

Configuration

Log level is a user-facing setting in the account/global TOML config:

[logging]
level = "info"          # One of: error, warn, info, debug, trace

RUST_LOG env var takes precedence when set (development override). Without RUST_LOG, the configured level applies to Posthaste crates only (posthaste_server, posthaste_engine, posthaste_imap, posthaste_store, posthaste_domain) while dependency crates default to warn. This keeps debug development logs useful without recording protocol-parser span noise from dependencies. If the tracing-subscriber reload handle is straightforward to wire, level changes from config apply at runtime without restart. Otherwise, restart-to-apply is acceptable for v1.

Log file management

  • Rotation: daily, via tracing-appender::rolling::daily
  • Retention: 7 days — older files deleted on rotation
  • Location: <data_dir>/logs/ (resolved from the existing config root directory logic)
  • Span close events are not emitted by default. Long-lived or high-frequency dependency spans can otherwise dominate JSON logs without adding operator-level diagnostics.

Log querying

Dev stacks expose the active persisted log path through just dev log path and follow it with just dev log tail. Structured JSONL logs are queried with:

just dev log query --account local-stalwart --message sync completed
just dev log query --event http.request.completed --operation-kind mail.search
just dev log query --sync-id 6f2a4a72-0c59-4d89-9d4e-2a2b9f2c4a87
just dev log query --operation-id op_9fd8 --json --limit 50
just dev log query --target posthaste_imap --json --limit 20

The query helper supports filtering by level, target substring, event name, account ID, sync ID, request ID, operation ID, operation kind, operation source, session ID, message substring, timestamp lower bound, and compact JSON output. Structured field filters check event fields plus active span fields so events emitted from nested spans still match.

Log record schema

Application log events use a stable structured schema so local logs can later feed beta telemetry without reverse-engineering message text.

Field Required Meaning
event SHOULD Stable machine-readable event name. Dot-separated lower-case namespaces, e.g. http.request.completed.
message MUST Human-readable summary. The message may change for clarity and must not be used as the primary analytics key.
target MUST Rust module target or frontend for frontend-forwarded logs.
level MUST Severity emitted by the subscriber (ERROR, WARN, INFO, DEBUG, TRACE).
error SHOULD on failures Sanitized error summary. Include operational context as separate fields.
*_id SHOULD where applicable Stable non-secret identifiers such as account_id, request_id, operation_id, sync_id.
*_count SHOULD for counts Integer counts, e.g. message_count, event_count, failed_count.
*_bytes SHOULD for byte sizes Integer byte counts, e.g. fetch_byte_limit, cached_bytes.
*_ms SHOULD for durations Integer milliseconds, e.g. latency_ms, duration_ms, elapsed_ms.

Event names describe what happened, not where it was logged: <domain>.<object>.<outcome> or <domain>.<operation>.<stage>. Examples: api.request.started, api.request.completed, http.request.completed, cache.maintenance.completed, sync.cycle.failed. New user-facing or telemetry-relevant logs must include event.

Rust application events are emitted through posthaste-observability typed macros such as ph_info!(events::HTTP_REQUEST_COMPLETED, ...). The LogEvent constructor is private, so new event names must be added to the shared registry before code can compile against them. Raw tracing::{trace,debug,info,warn,error}! event macros are rejected by the Rust logging contract check wired into backend build/check recipes.

Frontend application events are emitted through typed logger instances from src/logger.ts. Their first argument must include event: LogEvent, where LogEvent comes from src/logEvents.ts. The frontend logging contract check rejects direct pino imports outside the logger adapter and event string literals outside the event registry.

Noise budget

Logs should record decisions and outcomes, not every idle poll. Periodic background workers should avoid DEBUG events when no work was found, unless the event has an operation ID, reports an error, or changes a rate/backoff/resource decision. Candidate-level cache details and protocol wire details belong at TRACE.

Correlation metadata

Local logs must carry stable, non-secret identifiers that make one user action queryable across the webview, HTTP boundary, backend handlers, and background work it triggers.

Field Meaning
request_id One HTTP request. Generated by the frontend API client; generated by the backend if absent.
operation_id One user-facing operation, such as one message-list load or search preview. Reused by every HTTP request produced by that operation.
operation_kind Low-cardinality operation name such as mail.list, mail.search, or mail.search.preview.
operation_source UI or subsystem that created the operation, such as message-list.smart-mailbox or command-palette.
session_id One frontend observability session, stored in browser session storage.
process_id Operating-system process ID for the emitting process.
process_role Stable process role such as backend or webview.

The frontend sends correlation metadata to the backend on API calls using these headers: X-PostHaste-Request-Id, X-PostHaste-Operation-Id, X-PostHaste-Operation-Kind, X-PostHaste-Operation-Source, and X-PostHaste-Session-Id. The backend accepts only short ASCII token values for these fields before recording them in logs. The desktop IPC log bridge applies the same rule to frontend-forwarded event and correlation fields.

Span conventions

Spans use a domain.operation naming pattern with structured fields:

Span Fields Crate
http.request method, path, status, latency_ms, request_id, operation_id, operation_kind, operation_source, session_id, process_id, process_role posthaste-server (tower-http)
sync.cycle account_id, mailbox_count, email_count posthaste-engine
sync.method_call method_name, state_token posthaste-engine
push.connection account_id, transport (ws/sse), target_url, attempt posthaste-engine
push.event event_type, changed_types posthaste-engine
supervisor.action account_id, action posthaste-server
store.query operation, table posthaste-store

Start coarse-grained (the above list). Add finer spans based on debugging experience.

Event content rules

Spans and span conventions define where instrumentation lives. This section defines what each event must contain so that failures are diagnosable from logs alone, without attaching a debugger or adding ad-hoc print statements.

The guiding test: given a failure in production, can an operator identify what failed, where the request went, and what the system decided by reading the logs at INFO level? If not, the instrumentation is incomplete.

Connection events

Any event that opens a network connection (HTTP, WebSocket, SSE, TCP) must include the target URL or host:port as a structured field. This applies at both initial connection and reconnection.

When a connection attempt fails, the target must appear in the WARN/ERROR event. The error message alone ("400 Bad Request") is not sufficient — the target tells the operator where to look.

When a connection succeeds, log the target at INFO alongside any negotiated parameters (protocol version, supported capabilities).

Capability and negotiation events

When the system discovers capabilities from an external server (JMAP session, WebSocket support, push support), the discovered values must be logged at INFO. This includes URLs, feature flags, and version identifiers that influence subsequent behavior.

When the system makes a transport or strategy decision based on those capabilities (e.g. "use WS as primary, SSE as fallback"), log the decision and the inputs that drove it. Silent negotiation makes failures look like they come from nowhere.

Retry and fallback events

Events within a retry or fallback loop must include the attempt number and the threshold that triggers the next stage. A WARN event saying "push transport open failed" should read attempt=2 fallback_threshold=3 so the operator knows how close the system is to escalating.

When a fallback fires, log both the transport being abandoned and the transport being tried, along with the cumulative failure count.

When backoff delays are applied, log the delay duration. This prevents confusion about apparent inactivity in the logs.

Lifecycle transitions

State machine transitions that change an account or subsystem's operational status (Offline -> Syncing, Connected -> Reconnecting, Primary -> Fallback) must produce an INFO-level event. The event must include the previous state and the new state, not just the new state.

Error context preservation

Errors that cross crate or module boundaries (returned from trait methods, mapped through error enums) lose context at each mapping step. A raw jmap_client::Error carries connection details; by the time it becomes GatewayError::Network(String), only the message survives.

The rule: when mapping an error at a boundary, attach the operational context that the caller has but the error doesn't. This means the site that called the failing function adds its own fields, since it knows the target URL, account ID, transport type, or method name that the lower-level error cannot.

In practice, this means the warn! or error! event at the handling boundary includes both the mapped error and the contextual fields as structured tracing fields, not concatenated into the error string.

Do not embed operational context (URLs, account IDs) into error variant payloads. Errors describe what went wrong; the log event at the handling boundary describes where and during what.

Sensitive data rules

Log events must never contain credentials, tokens, passwords, or session secrets at any level. This is not a level-gating rule — these values are banned from log output entirely.

Email body content (HTML or plain text) must not appear in logs. Body-related log events use metadata only: message ID, content type, byte length.

Email addresses may appear at DEBUG and below for debugging sync and delivery flows. At INFO and above, use account ID or mailbox ID as the identifier.

Raw JMAP request/response payloads may appear at TRACE only. DEBUG-level method call events use structured fields (method name, object count, state token) rather than serialized JSON.

Error logging

Errors that cross crate boundaries (returned from trait methods) are logged at the boundary where they are handled, not where they originate. This avoids duplicate logging. Use tracing::error! with the error as a field: error!(error = %e, "sync cycle failed").

Frontend

Library

pino — structured, leveled, browser-compatible.

Interface

import pino from "pino";
import { invoke } from "@tauri-apps/api/core";

// In Tauri, pino's browser.write is replaced with a custom handler
// that calls invoke("log_from_frontend", {
//   level,
//   domain,
//   message,
//   event,
//   requestId,
//   operationId,
//   operationKind,
//   operationSource,
//   sessionId,
// }).
// In browser-only mode, falls back to { asObject: true } (console).
const logger = pino({
  level: import.meta.env.DEV ? "debug" : "info",
  browser: isTauri() ? makeTauriWrite() : { asObject: true },
});

// Domain-scoped child loggers — the domain field flows through IPC
const syncLogger = logger.child({ domain: "sync" });
const uiLogger = logger.child({ domain: "ui" });
const apiLogger = logger.child({ domain: "api" });

Log levels

Same semantics as the Rust side: error, warn, info, debug, trace.

Sink

In Tauri (desktop), pino logs are forwarded to the Rust tracing subscriber via a log_from_frontend IPC command, landing in the same daily-rotated JSON log files as backend events. The domain field from pino child loggers is preserved as a structured field with target: "frontend".

WebKit console output (console.log/info/debug/warn/error) is also captured and forwarded with domain: "webview", catching logs from React, third-party libraries, and uncaught errors. Pino log objects are detected and skipped in the console capture to avoid double-sending.

Frontend pino logs may include event, requestId, operationId, operationKind, operationSource, and sessionId. The IPC bridge preserves event and maps those camelCase fields into the backend log fields request_id, operation_id, operation_kind, operation_source, and session_id; missing fields are recorded as empty strings.

In browser dev mode (no Tauri), pino logs go to the browser console only. In Tauri dev mode, logs go to both the console (for devtools) and the backend.

Assertions

ID Sev. Assertion
stderr-human MUST Stderr output uses human-readable format with ANSI colors
file-json MUST File output uses JSON-lines format, one object per line
daily-rotation MUST Log files rotate daily with filenames containing the date
seven-day-retention SHOULD Log files older than 7 days are deleted automatically
config-level MUST Log level is configurable via [logging].level in TOML config
env-override MUST RUST_LOG env var overrides config-file level when set
app-scoped-default-filter SHOULD Without RUST_LOG, configured debug/trace levels apply to Posthaste crates while dependency crates stay at warn-or-higher
query-helper SHOULD Dev tooling provides a JSONL log query helper with account, sync ID, target, level, message, and since filters
query-helper-event SHOULD The JSONL log query helper can filter by stable event name
query-helper-correlation SHOULD The JSONL log query helper can filter by request ID, operation ID, operation kind, operation source, and session ID
stable-event-field SHOULD New telemetry-relevant log events include a stable dot-separated event field separate from human message
typed-log-events MUST Application log calls use typed event constants/unions rather than ad-hoc event strings
log-contract-checks MUST Backend build/check and frontend typecheck/build reject raw logging APIs that bypass the typed event contract
request-correlation MUST HTTP request logs include request_id, generated by the frontend when possible and by the backend otherwise
operation-correlation MUST User-facing frontend operations propagate operation_id, operation_kind, and operation_source through API requests and related backend logs
process-metadata SHOULD Backend and frontend-forwarded logs include process_id and process_role
search-cache-operation SHOULD Search-triggered cache maintenance logs preserve the operation ID that triggered the work
noise-budget SHOULD Periodic background workers avoid DEBUG logs for idle no-op ticks unless an operation ID, error, or resource decision changed
coarse-spans MUST Spans exist at HTTP request, sync cycle, push connection, and store query boundaries
fe-pino MUST Frontend uses pino with domain-scoped child loggers
error-boundary SHOULD Errors are logged at the handling boundary, not the origination point
conn-target MUST Connection attempt and failure events include the target URL or host:port
capability-log MUST Discovered server capabilities (URLs, feature flags) are logged at INFO on session establishment
negotiation-log MUST Transport selection decisions are logged with the chosen transport, alternatives, and rationale
retry-state MUST Retry/fallback events include attempt number and threshold
error-context SHOULD Error-handling log events include operational context (target, account, transport) as structured fields, not embedded in the error string
no-secrets MUST Credentials, tokens, passwords, and session secrets never appear in log output at any level
no-body-content MUST Email body content (HTML or plain text) never appears in log output
pii-level-gate SHOULD Email addresses appear only at DEBUG and below; INFO and above use account/mailbox IDs
payload-trace-only SHOULD Raw JMAP request/response payloads appear only at TRACE level
fe-tauri-bridge MUST In Tauri, pino logs are forwarded to the Rust tracing subscriber via the log_from_frontend IPC command
fe-webview-capture MUST In Tauri, WebKit console output is captured and forwarded with domain: "webview"
fe-no-double-send MUST Pino log objects routed through console in dev mode are not re-forwarded by the console capture
fe-browser-fallback MUST In browser-only mode (no Tauri), pino logs go to the browser console without errors