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
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")]