ADR-0037: logfmt diagnostics, retention caps, and cross-process correlation
Date: 2026-06-30 / Status: Accepted (no wire-contract / golden / ABI change; the correlation reuses ids already on the wire)
Current-state amendment (2026-07-25): persisted diagnostics are now a strict privacy boundary. Arbitrary strings are redacted at the Rust sink; C# records only exception type/HRESULT/Win32 code; crash markers and panic hooks never persist messages, payloads, locations, stacks, or backtraces. Diagnostic-copy output applies the same redaction.
Context
Logging was already disciplined — tracing + a non-blocking daily appender + a DiagLayer fanning WARN+ to the diag ring and the UI on the engine side (ADR-0018’s degrade!), and a hand-rolled FileLog with crash markers and exception funnels on the app side. But against industry-standard structured logging four gaps remained:
- No retention cap (a real bug).
tracing_appender::rolling::dailynever deleted the old dated engine-log files — they accumulated forever. The app kept a single.oldgeneration. - Not structured. Both sides wrote freeform human strings;
tracing’s spans were wired but unused. Neither log was machine-parseable (grep/awk), and fields were not first-class. - No cross-process correlation. A single user query produces lines in both
app.logand the engine log (two processes on the pipe path, two files even in-process on the FFI path) with nothing tying them together. - No injection / redaction policy. Query text and filenames — the product’s sensitive asset (the whole index is filenames) — were logged verbatim, and nothing sanitised CR/LF or control characters out of values (log-injection / forged-line risk).
Decision
Adopt one logfmt line schema as the canonical format for both languages, cap retention, and correlate the two logs using ids that already exist — so the contract is untouched.
- logfmt schema (the canonical surface). Each line is
ts level area [field=value …] msg="…" [err="…"]:ts= RFC3339 with the local UTC offset (2026-06-30T12:34:56.789+09:00);levelis a width-5 tag;areais the subsystem (query/scan/snapshot/pipe/…).- A value is emitted bare unless it contains a space,
=,",\, or a control char (< 0x20); then it is"…"-quoted with"→\",\→\\,\r/\n/\t, and other control chars →\uXXXX. Values are capped at 1 KiB with a…marker. - Engine: a custom
tracing_subscriber::FormatEvent(LogfmtFormatinfmf-core::diag) plus a matchingFormatFieldsso span fields render the same way. App: a SerilogITextFormatter(LogfmtFormatter).
- Retention caps. Engine moves to
RollingFileAppender::builder().max_log_files(N)(N = 14 for the resident service, 7 for FFI/CLI). App uses Serilog’s File sink withfileSizeLimitBytes = 5 MiB,rollOnFileSizeLimit,retainedFileCountLimit = 5. - Cross-process correlation — contract-unchanged. The engine groups a request’s log lines under a
qidspan (pipe: the framerequest_id, already client-generated and echoed; FFI: an in-process counter). The per-query “query served” line — emitted once by each transport, where the result handle exists — carriesrid: the resultId on the pipe, the boxed result handle’s address on the in-process FFI path. The UI logs the sameridfromSearchAsyncon both transports.ridis the universal app↔engine join key;qidadds intra-engine request grouping. The query line is skipped for an unchanged idle USN requery, mirroring the UI’sRefreshInPlace. - Security. The logfmt quoting is the log-injection defence (CR/LF can never escape a value). Query text is never logged — only
qlen— because filenames/queries are the sensitive asset (redaction); the existing%ProgramData%DACL + no-telemetry posture still apply. The app facade (FileLog) takes scalar strings only and the ADR forbids Serilog destructuring ({@obj}) so an object graph can never be expanded into the log. - C# adopts Serilog, used directly (no
Microsoft.Extensions.Logging/ DI) to stay closest to the existing staticFileLogfacade.FileLogkeeps its public surface (Info/Warn/Error + a new Debug and a structuredEvent) and routes through Serilog; the crash marker andTailstay hand-rolled (a marker must survive a hard crash that never flushes the logger).
The change flow is one-directional and stops short of the contract: prose here → LogfmtFormat/LogfmtFormatter → both languages’ tests green. fmf-contract / fmf-proto / contract/golden are not touched, proven by the golden suites staying green unmodified.
Rationale
- logfmt over JSON-lines: the consumers are a human reading the file and the F12 “copy diagnostics” dump; logfmt keeps human readability while making fields machine-parseable. NDJSON would win only for an ingestion pipeline we explicitly do not have.
- Reuse
request_id/ridover a new field: the pipe frame header already carries a client-generatedrequest_id, and the result handle is already returned to the UI — correlation is therefore a logging change, not a wire change. Adding aqidtoFmfQueryOptions(the alternative) would have been a golden-breaking contract change for no extra capability. - Span-based
qid: a per-request span means every line a request emits (including adegrade!warn mid-query) inherits the id automatically — no threading an id through every call site. - Transport-level “query served”:
ridis allocated by the transport, not byEngine::query; emitting the line there is the one place that has the trace and the handle, giving one fully-correlated line instead of two.
Trade-off
The engine timestamp caches the local UTC offset once at process start (resolving the zone per line would dominate the formatter), so a DST boundary crossed mid-process stamps subsequent lines with the pre-transition offset — harmless for logs. On the FFI path the engine’s qid counter and the UI’s logs do not share a qid (no wire id exists in-process); they join on rid instead, which is sufficient. Query errors (which produce no result handle) are not rid-correlated; the engine still logs them under its qid span and the UI logs them separately.
Rejected alternatives
- OpenTelemetry / OTLP export, or any collector. Rejected: the product is local-only with a permanent no-telemetry posture, the on-disk index is the sensitive asset behind a DACL, and the query hot path holds a single-digit-ms p99 budget — a collector/exporter on that path is unjustifiable. On-machine logs + the diag ring +
fmf_engine_statscover every need. - NDJSON (one JSON object per line). Rejected for the file format: it halves human readability for a tool we do not run. (The diag ring is already
Serialize-able if a JSON view is ever wanted.) - Adding
qidto the query contract (FmfQueryOptions/QueryTrace). Rejected: it breaks golden bytes / the C# DTO for a correlation we get for free from the existingrequest_id+rid. Microsoft.Extensions.Loggingabstraction in the app. Rejected: it pressures a DI container into a hand-wired WinUI composition root; direct Serilog maps 1:1 onto the existing staticFileLogcalls.Serilog.Sinks.Async. Rejected: an async sink can lose the last lines on a hard crash, violating “don’t go silent”; the synchronous File sink keeps them.
Consequences
- No wire-contract / golden / ABI change;
init_diaggrows amax_log_filesargument (internal). The former fixed engine-log name gains a date (engine.<date>.log); F12 “open log folder” is unaffected, andTailstill reads the fixedapp.log. - New deps:
Serilog+Serilog.Sinks.File(managed-only, ~1 MB; bundle size unaffected). No new Rust dependency — the formatter is hand-rolled onstd+ the index’s existing civil-date math. - A new counter is not added; no new
degrade!path is introduced, so themetrics.rs/COUNTER_NAMES/contract-gentriple is untouched.
Verification
Formatter tests pin quoting, CRLF neutralization, truncation, field order, correlation, and exception-text rejection in both languages. Crash-marker, diagnostic-copy, retention, and golden tests own the remaining privacy and contract guarantees.
Re-examination triggers
- If a genuine off-machine aggregation need ever appears (it should not, given the no-telemetry posture), revisit the NDJSON/OTLP rejections — but only behind an explicit opt-in.
- If query-error correlation becomes important, add an
rid-less error line under the engine’sqidspan and a matching app-side field.