Repository navigation
fix(server): log engine failures by their class outside the Blob routes - #926
Conversation
Change-route 500s, export and change-baseline streams that fail after their headers, stored-query invocations and startup open failures wrote engine error text or a raw storage root to the server log; that text can hold object URIs, credentials and entity IDs. Each now logs the error's class through the Blob routes' RedactedCause, moved to its own module, or a redacted root. The server subscriber holds Lance's dataset-event and file-audit targets to WARN/ERROR as it does rmcp, and a refused inline embedding api_key is no longer echoed.
…es; tighten the stored-query log assertion
ragnorc
left a comment
There was a problem hiding this comment.
Recommendation: approve. I found no blocking defect in aa6229f2989499a3ee3b27e84eea1604b01b3833.
This PR keeps private engine text and storage identifiers out of several server log paths. It logs a typed error class, uses the public graph ID for stored-query calls, and gives a failed response stream a fixed error message. It also blocks verbose Lance dataset and file events even when RUST_LOG requests them. Clients still get the existing change-route status and error contract. Operators retain the failure class, stream name, and recovery operation ID.
The fix addresses the cause at shared boundaries. RedactedCause retains no engine message and unwraps completion evidence to classify its cause. ExportSender::finish accepts the typed failure, so export and change-baseline producers share the same rule. This matters because tower-http classifies a body error through its Display text. Redacting only the producer's own log would leave that second path open.
The subscriber filter is independent of the environment filter. It matches complete target components and preserves WARN/ERROR and unrelated execution events. I checked the pinned Lance 11.0.0 implementation: dataset loading emits the full URI at INFO under lance::dataset_events; file creation/deletion uses lance::file_audit. This is a target-level rule for those event families, rather than a replacement rule for selected URI strings.
Tradeoff and liability: logs lose free-form engine detail and verbose Lance audit events. That reduces diagnostic detail, but removes multiple disclosure paths. Moving the existing class helper to one owner and moving stream redaction into the shared sender reduce maintenance liability. There is no new durable state, storage protocol, or public API. The static stream label adds a small diagnostic obligation. New error variants must choose a class, and changes to upstream log targets need another audit. Five more stream producers can reuse the same terminal boundary without five local sanitizers. I found no avoidable new abstraction in this change.
Validation:
- Local exact-head checks passed: four
change_route_error_tests, twolog_filter_tests, the existing terminal-frame/cut-lifetime test, and the startup credential-redaction test. - Before/after proof: I temporarily restored only
change_route_errorfrom pre-fix commit4c7a160acd1ae689773613c30069d4fed27f19e0, retaining the reviewed tests.cargo test -p omnigraph-server --locked --lib change_route_error_tests -- --nocaptureran four tests and failed all four. The captured output exposed the old private paths/recovery reason and lacked the new class fields. Restoring exact head and running the same command passed all four. This is a controlled old-function comparison, not a test run of the entire pre-fix tree. Temporary edits are restored. - Local commands used Rust 1.97.1,
RUSTFLAGS='--cfg tokio_unstable --cfg tokio_unstable', and a reused target directory. The three other focused filters ran through the resulting exact-head test binary. Docs and AGENTS link checks, plusgit diff --check, passed. - Exact-head server CI passed: 198 library tests, zero failures, two ignored, plus the integration suites. I checked the job log for the new assertions. Ordinary GQT and workspace CI were still pending at this review; DST and GQT-DST had passed.
GQT cannot capture server tracing fields or inject this native stream failure. These minimal assertions belong in the existing Rust owners. The local before/after proof covers the change-route mapper; the other paths have exact-head local/CI evidence, not local old-tree proof. I did not measure performance or run a cloud deployment. This review does not establish universal redaction of all server error responses or all dependency logs. Embedders that install their own subscriber must apply the documented target restrictions.
Summary
This audits every log site in
crates/omnigraph-server/src, and the server-facing error paths it reaches, for the leak the Blob routes already close: engine error text written to a server log. That text can hold object URIs, external Blob URIs, credentials or entity IDs. RFC 0033 §11 forbids logging URI credentials or complete sensitive URIs.Five paths outside the Blob routes leaked. Each now logs the error's class through the Blob routes'
RedactedCause, which moves to its own module because it is no longer Blob-specific, or logs a redacted value. Responses are unchanged.GET /changes,POST /changes/baseline,GET /commits/{commit_id}/changes), internal failureerror!(error = %other)logged the fullOmniError.error_kind="change_route_internal"witherror_variant,storage_kindandmanifest_kind.RecoveryRequiredwarn!loggedreason, engine text the response deliberately hides.operation_idonly.Manifest::NotFounddebug!logged the internal message the response deliberately replaces.OmniErrortext became the body'sio::Error. The client never reads it, but tower-http'sTraceLayerlogs a body error'sDisplayat ERROR (classification=Error: …).ExportSender::finishtakes theOmniError, logserror_kind="served_stream_failed"withstreamand the class, and ends the body with a constant. Because the redaction lives in the transport, no producer can pass raw text again.POST /queries/{name}info!(graph = %handle.uri)logged the graph's storage root on every invocation.graph_id.open graph … at {uri}), andgraph blocked during startuplogged it.redacted_storage_uri, as other server diagnostics already do.lance::dataset_eventsandlance::file_auditinfolevel the server logged every table load, write, commit, compaction and cleanup with its full storage URI, every file Lance created or deleted, and each delete predicate, which names entity IDs.server_log_subscriberholds both targets to WARN/ERROR whateverRUST_LOGsays, likermcp.resolve()of an inlineapi_keygot '…'), andsettings.rslogsresolve()errors at WARN.Reviewed, no change needed
Authorization.log_policy_decision,graph routedandcommit lookup branch disappearedlog graph vocabulary only: actor ID, branch names, commit IDs.redacted_storage_uriroots.settings.rs. Cluster diagnostics carry config paths and messages; cluster validation redacts roots and refuses inline secrets. Stored-query parse errors andlog_registry_warningsare query-text diagnostics from operator config.deployment.rsuncertain(). It logs and returns the same reconciliation message to the authorized cluster administrator. Its parts are cluster and config errors, and the storage crate has already redacted any storage error among them.workload.rslogs numeric limits from env;oidc_identity.rslogs a constant.lance::io_eventscarries index part IDs andlance::executioncarries plan counters.lance::object_store::throttlelogs object-store error text only at DEBUG.ApiErrorbody to the same requester, andrmcppayload logs stay filtered.client.rstests already assert a bearer never appears in its errors.Findings left for a decision (not changed here)
/query,/mutate,/load,/exportbefore headers, schema, branches, commits), 500 bodies returnOmniError::Storagetext,BlobIntegrityreasons andIotext to an authenticated caller. The storage crate strips credentials when it builds those messages, but they still name physical object paths. The Blob and change routes collapse 500s to constants. Making that uniform changes the wire contract of every route and needs its own decision.StoredExternalBlobDeniedon/mutate. This 400 includes the stored external URI./mutatechecks onlychange, so an actor grantedchangewithoutreadon a branch can learn a stored URI. RFC 0033 §7.3 grants URI disclosure to Blob readers, and §11 says this error names "type, id, and property".normalize_root_urirefuses neither userinfo nor a query on ans3://graph root; Azure roots refuse userinfo. Redacting diagnostics mitigates this. Refusing such roots at config would remove the source.InsertError::DuplicateUriandInvalidUrishow a raw root in a startup refusal or deployment response to the operator. Exposure is low, and the root cause is the one above.Tests
The log assertions live in-source, because
test_log_captureis crate-internal. Each new assertion was checked to fail with the old behavior restored.handlers::change_route_error_tests(existing owner): every test now captures the log at DEBUG. It asserts that the class fields are present and that the text the response withholds is absent from the log.export_transport::tests::completed_producer_transfers_root_cut_to_terminal_frame(existing owner): an engine failure carrying a bucket, a key and a token reaches the body as the constant and the log as its class. As before, a failure after the client has gone is not logged.log_filter_tests::restricted_dependency_logs_remain_filtered_under_specific_directives(renamed fromverbose_sdk_payloads_…): covers the Lance targets undertraceand target-specific directives. Sibling targets such aslance::executionstay visible.log_filter_tests::stored_query_invocation_logs_the_graph_id_not_its_storage_root: drives the real route throughbuild_app. The whole log is checked, so real Lance events also prove the root never reaches it, which guards against a Lance target rename.external_blob_startup_tests::blocked_startup_logs_the_root_without_credentials: afile://root with a query and fragment.omnigraph-clusterrejects_inline_api_key: the refusal does not echo the value.