config: read the log filter from docket.kdl
EnvFilter::from_default_env() with RUST_LOG unset disables every event, so deploys that never set it logged nothing. The level defaults to warn so a deploy can raise just docket: log "warn" docket="info".

Assisted-by: GLM-5.3 via pi
change ukrssprqpktxkoqxmppsvmoxkpmsuovx
commit 30edf9c873322d8491851f8c408e2cd02e33a168
author Alpha Chen <alpha@kejadlen.dev>
date
parent npwnypnu
diff --git a/README.md b/README.md
index d8a9441..799251e 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,7 @@ credential "household" token-file="/run/credentials/docket/household"
 |---|---|---|
 | `bind` | `127.0.0.1:3000` | Address to listen on. |
 | `database` | `docket.db` | SQLite database file, created if missing. |
+| `log` | `warn` | Log level for the app, with optional per-target overrides as properties: `log "debug" hyper="warn"`. |
 | `credential` | None | A Fastmail API token to sync mail with. Repeat it once per login. |
 
 Each `credential` takes a name, which becomes the account's slug, and a
diff --git a/docket.kdl b/docket.kdl
index 9ff3a22..3c2b994 100644
--- a/docket.kdl
+++ b/docket.kdl
@@ -4,6 +4,7 @@
 // first time and stands in for Tailscale. Delete docket.db to start over.
 bind "127.0.0.1:3000"
 database "docket.db"
+// log "warn"
 
 // A Fastmail API token per login (ADR 1). The name is the account's slug;
 // the token lives in the named file, never in this config. Local dev runs
diff --git a/fly.kdl b/fly.kdl
index 86a9d3b..4c711f2 100644
--- a/fly.kdl
+++ b/fly.kdl
@@ -8,3 +8,4 @@
 // replaces, so each deploy starts from the fixtures again.
 bind "0.0.0.0:3000"
 database "/tmp/docket.db"
+log "info"
diff --git a/src/bin/docket/config.rs b/src/bin/docket/config.rs
index 21591ec..43451fc 100644
--- a/src/bin/docket/config.rs
+++ b/src/bin/docket/config.rs
@@ -12,9 +12,13 @@ use serde::Deserialize;
 use serde::Deserializer;
 use serde::de;
 use serde::de::Visitor;
+use std::str::FromStr as _;
+use tracing::level_filters::LevelFilter;
+use tracing_subscriber::filter::Targets;
 
 const DEFAULT_BIND: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3000);
 const DEFAULT_DATABASE: &str = "docket.db";
+const DEFAULT_LOG: LevelFilter = LevelFilter::WARN;
 
 #[derive(Debug, Deserialize)]
 #[serde(deny_unknown_fields)]
@@ -29,6 +33,11 @@ pub struct Config {
     #[serde(default = "default_database", deserialize_with = "non_empty_path")]
     pub database: Utf8PathBuf,
 
+    /// Log level as the node argument, with optional per-target
+    /// overrides as properties: `log "debug" hyper="warn"`.
+    #[serde(default = "default_log", deserialize_with = "log_targets")]
+    pub log: Targets,
+
     /// One node per Fastmail API token (DESIGN.md, Storage):
     /// `credential "household" token-file="/run/credentials/docket/household"`.
     #[serde(default, rename = "credential")]
@@ -78,6 +87,71 @@ fn default_database() -> Utf8PathBuf {
     Utf8PathBuf::from(DEFAULT_DATABASE)
 }
 
+fn default_log() -> Targets {
+    Targets::new().with_default(DEFAULT_LOG)
+}
+
+fn log_targets<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Targets, D::Error> {
+    deserializer.deserialize_struct("Log", LOG_FIELDS, LogNode)
+}
+
+/// The kdl field names of a `log` node: the level argument (`#0`) and a
+/// would-be second (`#1`, rejected). Properties override per target.
+const LOG_FIELDS: &[&str] = &["#0", "#1"];
+
+struct LogNode;
+
+impl<'de> Visitor<'de> for LogNode {
+    type Value = Targets;
+
+    fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.write_str("a log node")
+    }
+
+    fn visit_map<A: de::MapAccess<'de>>(self, mut map: A) -> Result<Targets, A::Error> {
+        let mut level = None;
+        let mut overrides = Vec::new();
+        while let Some(key) = map.next_key::<String>()? {
+            match key.as_str() {
+                "#0" => level = Some(map.next_value::<Level>()?.0),
+                "#1" => return Err(de::Error::custom("log takes one argument, its level")),
+                target => {
+                    let Level(target_level) = map.next_value()?;
+                    overrides.push((target.to_owned(), target_level));
+                }
+            }
+        }
+        let level =
+            level.ok_or_else(|| de::Error::custom("log needs a level argument: log \"info\""))?;
+        let mut log = Targets::new().with_default(level);
+        for (target, level) in overrides {
+            log = log.with_target(target, level);
+        }
+        Ok(log)
+    }
+}
+
+/// A level parsed inside `visit_str`, where kdl can label a bad value.
+struct Level(LevelFilter);
+
+impl<'de> Deserialize<'de> for Level {
+    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+        struct V;
+        impl<'de> de::Visitor<'de> for V {
+            type Value = Level;
+
+            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+                f.write_str("a log level")
+            }
+
+            fn visit_str<E: de::Error>(self, source: &str) -> Result<Level, E> {
+                LevelFilter::from_str(source).map_err(E::custom).map(Level)
+            }
+        }
+        deserializer.deserialize_str(V)
+    }
+}
+
 fn non_empty_path<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Utf8PathBuf, D::Error> {
     deserializer.deserialize_str(NonEmptyPath)
 }
@@ -104,7 +178,9 @@ impl Visitor<'_> for NonEmptyPath {
 mod tests {
     use miette::Diagnostic as _;
 
-    use super::{Config, parse};
+    use std::str::FromStr as _;
+
+    use super::{Config, LevelFilter, Targets, parse};
 
     /// The error's message, and whether it points into the source.
     fn error(source: &str) -> (String, bool) {
@@ -122,6 +198,7 @@ mod tests {
             let config = parse(source).unwrap();
             assert_eq!(config.bind.to_string(), "127.0.0.1:3000");
             assert_eq!(config.database, "docket.db");
+            assert_eq!(config.log, Targets::from_str("warn").unwrap());
         }
     }
 
@@ -136,6 +213,15 @@ mod tests {
         }
     }
 
+    #[test]
+    fn log_takes_a_level_with_target_overrides() {
+        let config = parse(r#"log "debug" hyper="warn""#).unwrap();
+        let expected = Targets::new()
+            .with_default(LevelFilter::DEBUG)
+            .with_target("hyper", LevelFilter::WARN);
+        assert_eq!(config.log, expected);
+    }
+
     #[test]
     fn bad_values_point_at_the_value() {
         for (source, message) in [
@@ -143,6 +229,11 @@ mod tests {
             ("bind 3000", "expected socket address"),
             ("database 1", "expected a non-empty path string"),
             (r#"database """#, "database path is empty"),
+            (r#"log "verbose""#, "error parsing level filter"),
+            (
+                r#"log "info" hyper="verbose""#,
+                "error parsing level filter",
+            ),
         ] {
             let (err, labeled) = error(source);
             assert!(err.contains(message), "{source}: {err}");
@@ -203,6 +294,8 @@ mod tests {
             ("dev", "unknown field `dev`"),
             ("bind", "expected socket address"),
             ("database", "expected a non-empty path string"),
+            ("log", "log needs a level argument"),
+            (r#"log "info" "debug""#, "log takes one argument, its level"),
             ("bind \"127.0.0.1:1\"\nbind \"127.0.0.1:2\"", "sequence"),
             (r#"database "a.db" "b.db""#, "sequence"),
             (r#"database path="a.db""#, "map"),
diff --git a/src/bin/docket/main.rs b/src/bin/docket/main.rs
index 63414be..f654628 100644
--- a/src/bin/docket/main.rs
+++ b/src/bin/docket/main.rs
@@ -6,20 +6,21 @@ use miette::IntoDiagnostic as _;
 use miette::WrapErr as _;
 use tokio::net::TcpListener;
 use tokio::signal;
-use tracing_subscriber::EnvFilter;
 use tracing_subscriber::fmt;
 use tracing_subscriber::prelude::*;
 
 #[tokio::main]
 async fn main() -> miette::Result<()> {
     miette::set_panic_hook();
-    tracing_subscriber::registry()
-        .with(fmt::layer())
-        .with(EnvFilter::from_default_env())
-        .init();
 
     let args = config::Args::parse();
     let config = config::Config::load(&args.config)?;
+    // Tracing starts after the config so its filter comes from the file;
+    // config errors report through miette, which needs no subscriber.
+    tracing_subscriber::registry()
+        .with(fmt::layer())
+        .with(config.log)
+        .init();
     // Dev pins the fixture clock so sample ages stay stable; production
     // runs on real time.
     #[cfg(feature = "dev")]