chore(logs): add explicit http logs

Default is now RUST_LOG=info,http=warn. Effect of each level on the access log:

  ┌────────────────────┬────────────────────────┐
  │   Level on http    │ Status classes emitted │
  ├────────────────────┼────────────────────────┤
  │ info               │ 2xx/3xx + 4xx + 5xx    │
  ├────────────────────┼────────────────────────┤
  │ warn (default)     │ 4xx + 5xx              │
  ├────────────────────┼────────────────────────┤
  │ error              │ 5xx only               │
  ├────────────────────┼────────────────────────┤
  │ off                │ nothing                │
  └────────────────────┴────────────────────────┘

  Target mapping:

  ┌────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┬─────────────────┐
  │                                                       Routes                                                       │     Target      │
  ├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼─────────────────┤
  │ health_routes                                                                                                      │ http::probe     │
  ├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼─────────────────┤
  │ magic_link_router                                                                                                  │ http::web       │
  ├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼─────────────────┤
  │ All /api/auth/* sub-routers (login, register, refresh, public, protected, app_pw, device_public, device_protected) │ http::api::auth │
  ├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼─────────────────┤
  │ setup_router, public_api_routes, protected_api, wopi_api_protected                                                 │ http::api       │
  ├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼─────────────────┤
  │ well_known_router, caldav_protected, carddav_protected, webdav_protected                                           │ http::dav       │
  ├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼─────────────────┤
  │ nc_router                                                                                                          │ http::nextcloud │
  ├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼─────────────────┤
  │ wopi_protocol                                                                                                      │ http::wopi      │
  ├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼─────────────────┤
  │ web_routes (+ ServeDir fallback)                                                                                   │ http::web       │
  └────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┴─────────────────┘

  # Default value:

  - **http=warn** if target http not specified
  - **http::web=error** if target http::web not specified

  Common operator overrides:

  # Server-error-only access logs (the new default)
  unset RUST_LOG

  # See login failures and other client errors on auth
  RUST_LOG=info,http=warn,http::api::auth=info

  # which is similar to
  RUST_LOG=info,http::api::auth=info

  # Full access log everywhere (heavy)
  RUST_LOG=info,http=info

  # Silence everything except errors
  RUST_LOG=warn
This commit is contained in:
Edouard Vanbelle
2026-06-08 08:37:16 +02:00
parent 06e4e56ce7
commit 4fc3746754
5 changed files with 438 additions and 78 deletions
+115 -48
View File
@@ -12,9 +12,8 @@ use socket2::{Domain, Protocol, Socket, TcpKeepalive, Type};
use axum::Router;
use axum::extract::DefaultBodyLimit;
use oxicloud::interfaces::middleware::trace_span::{
ClientIpMakeSpan, LogBadRequest, UuidRequestId,
};
use oxicloud::access_log;
use oxicloud::interfaces::middleware::trace_span::{ClientIpMakeSpan, UuidRequestId};
use tower_http::limit::RequestBodyLimitLayer;
use tower_http::request_id::{PropagateRequestIdLayer, SetRequestIdLayer};
use tower_http::set_header::SetResponseHeaderLayer;
@@ -178,11 +177,44 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
// Initialize tracing
// Initialize tracing.
//
// Default access-log policy — two independent directives are
// injected unless the operator has already named them:
//
// `http=warn` (4xx + 5xx for every access-log target)
// `http::web=error` (5xx only for static / ServeDir / catch-all)
//
// `http::web` is pulled down to ERROR because it's the noisiest
// surface (every CSS/JS/img/favicon request hits it) and its
// 4xx are almost always "browser asked for a file we don't ship",
// not a real signal. Operators investigating a 404 storm can
// promote it back: `RUST_LOG=info,http::web=warn`.
//
// The detection is substring-based:
// - `http=` in RUST_LOG → operator owns the http baseline.
// - `http::web=` in RUST_LOG → operator owns the web subtarget.
// The two are independent — supplying `http=info` still gets a
// free `http::web=error` unless the operator named that too.
//
// Empty / unset / no http directives → both defaults applied.
// Note that `http::web=…` does NOT contain `http=` as a substring
// (different characters around the `:`), so the two checks don't
// alias each other.
let rust_log = match std::env::var("RUST_LOG").ok().filter(|s| !s.is_empty()) {
None => "info,http=warn,http::web=error".to_string(),
Some(mut s) => {
if !s.contains("http=") {
s.push_str(",http=warn");
}
if !s.contains("http::web=") {
s.push_str(",http::web=error");
}
s
}
};
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
))
.with(tracing_subscriber::EnvFilter::new(rust_log))
.with(tracing_subscriber::fmt::layer())
.init();
@@ -487,38 +519,68 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let magic_link_router = interfaces::api::handlers::magic_link_handler::magic_link_routes()
.with_state(app_state.clone());
// Access-log targets are declared per-mount via `access_log!(…)`
// — see `interfaces/middleware/trace_span.rs` for the catalogue.
app = Router::new()
// Health / readiness probes — no auth, mounted at root
.merge(health_routes)
.merge(health_routes.layer(access_log!("http::probe")))
// Magic-link redemption — top-level, no `/api/` prefix
.merge(magic_link_router)
.merge(magic_link_router.layer(access_log!("http::web")))
// Rate-limited auth endpoints (login, register, refresh)
.nest("/api/auth", auth_login)
.nest("/api/auth", auth_register)
.nest("/api/auth", auth_refresh)
.nest(
"/api/auth",
auth_login.layer(access_log!("http::api::auth")),
)
.nest(
"/api/auth",
auth_register.layer(access_log!("http::api::auth")),
)
.nest(
"/api/auth",
auth_refresh.layer(access_log!("http::api::auth")),
)
// Public auth endpoints (status, OIDC)
.nest("/api/auth", auth_public)
.nest(
"/api/auth",
auth_public.layer(access_log!("http::api::auth")),
)
// Protected auth endpoints (/me, /change-password, /logout)
.nest("/api/auth", auth_protected)
.nest(
"/api/auth",
auth_protected.layer(access_log!("http::api::auth")),
)
// App password management (create, list, revoke)
.nest("/api/auth", app_pw_protected)
.nest(
"/api/auth",
app_pw_protected.layer(access_log!("http::api::auth")),
)
// One-time setup endpoint — public, rate-limited
.nest("/api", setup_router)
.nest("/api", setup_router.layer(access_log!("http::api")))
// Device Auth Grant public endpoints (authorize + token polling)
.nest("/api/auth/device", device_public)
.nest(
"/api/auth/device",
device_public.layer(access_log!("http::api::auth")),
)
// Device Auth Grant protected endpoints (verify + device management)
.nest("/api/auth/device", device_protected)
.nest(
"/api/auth/device",
device_protected.layer(access_log!("http::api::auth")),
)
// Public API routes (share access, i18n) — no auth required
.nest("/api", public_api_routes)
.nest("/api", public_api_routes.layer(access_log!("http::api")))
// All other API routes are protected by auth middleware
.nest("/api", protected_api)
.nest("/api", protected_api.layer(access_log!("http::api")))
// RFC 6764 well-known discovery (public, no auth — just redirects)
.merge(well_known_router.clone())
.merge(well_known_router.clone().layer(access_log!("http::dav")))
// CalDAV/CardDAV/WebDAV protocols merged at top-level for client compatibility
.merge(caldav_protected)
.merge(carddav_protected)
.merge(webdav_protected)
.merge(web_routes);
.merge(caldav_protected.layer(access_log!("http::dav")))
.merge(carddav_protected.layer(access_log!("http::dav")))
.merge(webdav_protected.layer(access_log!("http::dav")))
// Web (HTML pages) — also the ServeDir fallback root, so
// static asset hits land here. We keep them on the `web`
// target for simplicity; switch to `http::static` when
// the static surface is split into its own router.
.merge(web_routes.layer(access_log!("http::web")));
// Mount Nextcloud routes (uses its own Basic Auth middleware).
// **Merged BEFORE the trace + request-id layers** so NC requests
@@ -526,7 +588,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// fields as every other surface — see
// `interfaces/middleware/trace_span.rs::ClientIpMakeSpan`.
if let Some(nc_router) = nextcloud_router {
app = app.merge(nc_router.with_state(app_state.clone()));
app = app.merge(
nc_router
.with_state(app_state.clone())
.layer(access_log!("http::nextcloud")),
);
}
// Mount WOPI routes (protocol routes use own token auth, API routes behind auth middleware).
@@ -540,8 +606,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
auth_middleware,
));
app = app
.nest("/wopi", wopi_protocol)
.nest("/api/wopi", wopi_api_protected);
.nest("/wopi", wopi_protocol.layer(access_log!("http::wopi")))
.nest(
"/api/wopi",
wopi_api_protected.layer(access_log!("http::api")),
);
}
// ── Trace + request-id layers applied LAST so every route
@@ -550,11 +619,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// only have to be merged before this point to get tracing
// for free — no second site to remember to update.
app = app
.layer(
TraceLayer::new_for_http()
.make_span_with(ClientIpMakeSpan)
.on_response(LogBadRequest),
)
.layer(TraceLayer::new_for_http().make_span_with(ClientIpMakeSpan))
.layer(PropagateRequestIdLayer::x_request_id())
.layer(SetRequestIdLayer::x_request_id(UuidRequestId));
} else {
@@ -562,38 +627,40 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing::warn!("Authentication is DISABLED — all API routes are publicly accessible");
app = Router::new()
// Health / readiness probes — no auth, mounted at root
.merge(health_routes)
.nest("/api", public_api_routes)
.nest("/api", api_routes)
.merge(health_routes.layer(access_log!("http::probe")))
.nest("/api", public_api_routes.layer(access_log!("http::api")))
.nest("/api", api_routes.layer(access_log!("http::api")))
// RFC 6764 well-known discovery (just redirects)
.merge(well_known_router)
.merge(well_known_router.layer(access_log!("http::dav")))
// CalDAV/CardDAV/WebDAV protocols merged at top-level
.merge(caldav_router)
.merge(carddav_router)
.merge(webdav_router)
.merge(web_routes);
.merge(caldav_router.layer(access_log!("http::dav")))
.merge(carddav_router.layer(access_log!("http::dav")))
.merge(webdav_router.layer(access_log!("http::dav")))
.merge(web_routes.layer(access_log!("http::web")));
// Mount Nextcloud routes — merged BEFORE the trace + request-id
// layers so NC requests get the same span fields as every
// other surface (matches the auth-enabled branch above).
if let Some(nc_router) = nextcloud_router {
app = app.merge(nc_router.with_state(app_state.clone()));
app = app.merge(
nc_router
.with_state(app_state.clone())
.layer(access_log!("http::nextcloud")),
);
}
// Mount WOPI routes (no auth middleware when auth is disabled).
// Same reasoning: merge before the trace layer.
if let Some((wopi_protocol, wopi_api)) = wopi_routes {
app = app.nest("/wopi", wopi_protocol).nest("/api/wopi", wopi_api);
app = app
.nest("/wopi", wopi_protocol.layer(access_log!("http::wopi")))
.nest("/api/wopi", wopi_api.layer(access_log!("http::api")));
}
// ── Trace + request-id layers applied LAST. See the
// auth-enabled branch above for the rationale.
app = app
.layer(
TraceLayer::new_for_http()
.make_span_with(ClientIpMakeSpan)
.on_response(LogBadRequest),
)
.layer(TraceLayer::new_for_http().make_span_with(ClientIpMakeSpan))
.layer(PropagateRequestIdLayer::x_request_id())
.layer(SetRequestIdLayer::x_request_id(UuidRequestId));
}