diff --git a/.gitignore b/.gitignore index 0638cad4..bafd2dbc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ internal-docs/ target/ worktrees/ +# Host-mounted service logs (docker-compose bind mounts ./logs/). +logs/ .claude/roles/ .claude/commands.txt .claude/skills/reviewer-core/ diff --git a/Cargo.lock b/Cargo.lock index b2840b7b..130afcaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1548,6 +1548,7 @@ dependencies = [ "dodex-chain", "dodex-domain", "dodex-infrastructure", + "dodex-logging", "dotenvy", "hex", "hmac", @@ -1605,6 +1606,7 @@ version = "0.1.0" dependencies = [ "anyhow", "dodex-infrastructure", + "dodex-logging", "tokio", "tracing", "tracing-subscriber 0.3.23", @@ -1644,6 +1646,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "dodex-logging" +version = "0.1.0" +dependencies = [ + "anyhow", + "tempfile", + "tracing", + "tracing-appender", + "tracing-subscriber 0.3.23", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -5396,6 +5409,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -5875,6 +5894,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber 0.3.23", +] + [[package]] name = "tracing-attributes" version = "0.1.31" diff --git a/Cargo.toml b/Cargo.toml index dd3a663f..9dfb6736 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/domain", "crates/application", "crates/infrastructure", + "crates/logging", ] # `sdk/` is its own workspace, kept out of the root resolve on purpose: its # halo2 proof pipeline pulls a heavy, distinct zk/halo2 dependency graph. diff --git a/README.md b/README.md index b8c08dfa..8ef7fd5d 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,11 @@ Per-service config files live under `config/`: Local defaults: `config/api.local.yaml`, `config/indexer.local.yaml`. Override at runtime with `APP_CONFIG=/path/to/file.yaml`. +Logging is environment-driven: `RUST_LOG` sets verbosity, and `LOG_DIR` +(optional) makes each service also write rotated log files into a directory — +the Compose deployment bind-mounts these to `./logs/`. See +[docs/deployment.md](docs/deployment.md#logs). + Secrets and environment-specific values live in `.env`: ```sh diff --git a/crates/logging/Cargo.toml b/crates/logging/Cargo.toml new file mode 100644 index 00000000..a0893458 --- /dev/null +++ b/crates/logging/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "dodex-logging" +version = "0.1.0" +edition = "2024" +license = "LicenseRef-Acki-Nacki-Node-License" + +# Inline rather than `.workspace = true` so this crate can be consumed from +# `services/market-manager` (its own Cargo workspace) and from inside the +# market-manager Docker build, neither of which can reach the parent +# workspace's `[workspace.dependencies]`. Same rationale as `crates/chain`. +[dependencies] +anyhow = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +tracing-appender = "0.2" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/logging/src/lib.rs b/crates/logging/src/lib.rs new file mode 100644 index 00000000..abfb086c --- /dev/null +++ b/crates/logging/src/lib.rs @@ -0,0 +1,123 @@ +// 2026 (c) Copyright Contributors to the GOSH DAO. All rights reserved. +// + +//! Shared tracing setup for the dodex services. +//! +//! Every service logs to stdout, filtered by `RUST_LOG` (default `info`). +//! When the `LOG_DIR` environment variable is set and non-empty, each service +//! ALSO writes human-readable, daily-rotated files named `.log.` +//! into that directory, keeping at most `LOG_MAX_FILES` (default 14) of them. +//! +//! Lives in its own crate — free of the heavy `dodex-infrastructure` +//! dependency graph — so the standalone `market-manager` workspace can reuse +//! it by path, exactly like `dodex-chain`. + +use std::env; + +use tracing_appender::non_blocking::WorkerGuard; +use tracing_appender::rolling::RollingFileAppender; +use tracing_appender::rolling::Rotation; +use tracing_subscriber::fmt; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::EnvFilter; + +/// Daily log files retained before the oldest is pruned, when `LOG_MAX_FILES` +/// is unset or unparseable. +const DEFAULT_MAX_FILES: usize = 14; + +fn env_filter() -> EnvFilter { + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")) +} + +fn max_files() -> usize { + env::var("LOG_MAX_FILES").ok().and_then(|v| v.parse().ok()).unwrap_or(DEFAULT_MAX_FILES) +} + +/// Build the daily rolling-file appender for `service` under `dir`, creating +/// the directory if needed. Errors out (rather than panicking) so the caller +/// can fall back to stdout-only. +fn file_appender(dir: &str, service: &str) -> anyhow::Result { + std::fs::create_dir_all(dir)?; + let appender = RollingFileAppender::builder() + .rotation(Rotation::DAILY) + .filename_prefix(format!("{service}.log")) + .max_log_files(max_files()) + .build(dir)?; + Ok(appender) +} + +/// Install the global tracing subscriber for `service`. +/// +/// Always logs to stdout. If `LOG_DIR` is set and non-empty, also writes +/// daily-rotated `.log.` files there. On a log-dir error, warns +/// loudly to the (already-installed) stdout logger and continues stdout-only. +/// +/// Returns the appender guard(s). The caller MUST keep them alive for the +/// lifetime of the process (`let _guards = dodex_logging::init("api");`) — drop +/// them and the background file writer stops flushing. +#[must_use] +pub fn init(service: &str) -> Vec { + let stdout_layer = fmt::layer().with_writer(std::io::stdout); + + let log_dir = env::var("LOG_DIR").unwrap_or_default(); + if log_dir.is_empty() { + tracing_subscriber::registry().with(env_filter()).with(stdout_layer).init(); + return Vec::new(); + } + + match file_appender(&log_dir, service) { + Ok(appender) => { + let (writer, guard) = tracing_appender::non_blocking(appender); + let file_layer = fmt::layer().with_ansi(false).with_writer(writer); + tracing_subscriber::registry() + .with(env_filter()) + .with(stdout_layer) + .with(file_layer) + .init(); + vec![guard] + } + Err(err) => { + tracing_subscriber::registry().with(env_filter()).with(stdout_layer).init(); + tracing::warn!( + log_dir = %log_dir, + error = %err, + "LOG_DIR set but file logging could not be initialised; continuing with stdout only" + ); + Vec::new() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn writes_a_rotated_file_when_dir_is_set() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().to_str().expect("utf-8 path"); + + let appender = file_appender(path, "testsvc").expect("appender builds"); + let (writer, guard) = tracing_appender::non_blocking(appender); + let subscriber = + tracing_subscriber::registry().with(fmt::layer().with_ansi(false).with_writer(writer)); + + tracing::subscriber::with_default(subscriber, || { + tracing::info!("hello-from-test"); + }); + + // Dropping the guard flushes and joins the background writer thread. + drop(guard); + + let log_files: Vec<_> = std::fs::read_dir(dir.path()) + .expect("read tempdir") + .filter_map(Result::ok) + .filter(|e| e.file_name().to_string_lossy().starts_with("testsvc.log")) + .collect(); + assert_eq!(log_files.len(), 1, "exactly one rotated file is created"); + + let contents = std::fs::read_to_string(log_files[0].path()).expect("read log file"); + assert!(contents.contains("hello-from-test"), "log line written, got: {contents}"); + } +} diff --git a/docker-compose.stage.yml b/docker-compose.stage.yml index cf5fba54..abd74a75 100644 --- a/docker-compose.stage.yml +++ b/docker-compose.stage.yml @@ -16,11 +16,13 @@ services: environment: APP_CONFIG: /app/config/market-manager.stage.yaml RUST_LOG: info + LOG_DIR: /app/logs volumes: # Mount config dir read-only so YAML / events / secrets edits take # effect on container restart without rebuilding the image. - ./services/market-manager/config:/app/config:ro - market-manager-state:/state + - ./logs/market-manager:/app/logs restart: unless-stopped volumes: diff --git a/docker-compose.yml b/docker-compose.yml index c02d8600..7471e5a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,10 +6,12 @@ services: environment: APP_CONFIG: /app/config/api.local.yaml RUST_LOG: info + LOG_DIR: /app/logs ports: - "8080:8080" volumes: - ./config:/app/config:ro + - ./logs/api:/app/logs restart: unless-stopped healthcheck: # Probe the running API rather than spawning a second `dodex-api` @@ -28,6 +30,8 @@ services: environment: APP_CONFIG: /app/config/indexer.local.yaml RUST_LOG: info + LOG_DIR: /app/logs volumes: - ./config:/app/config:ro + - ./logs/indexer:/app/logs restart: unless-stopped diff --git a/docs/deployment.md b/docs/deployment.md index d82e4fe8..7d3fa1eb 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -252,8 +252,10 @@ api only: ## Step 4 — Compose override, build, and run The base `docker-compose.yml` mounts `./config` into each container read-only -(`/app/config`) and defaults `APP_CONFIG` to the `*.local.yaml` files. Add an -override that points `APP_CONFIG` at your own files — mirroring how +(`/app/config`), bind-mounts a per-service host log directory +(`./logs/api`, `./logs/indexer` → `/app/logs`) with `LOG_DIR=/app/logs` set, +and defaults `APP_CONFIG` to the `*.local.yaml` files. Add an override that +points `APP_CONFIG` at your own files — mirroring how `docker-compose.stage.yml` selects the Supabase configs. Create `docker-compose.prod.yml`: @@ -327,12 +329,30 @@ docker compose -f docker-compose.yml -f docker-compose.prod.yml kill -s SIGUSR1 ### Logs +Each service writes to **both** stdout and a host-mounted directory. The base +`docker-compose.yml` bind-mounts `./logs/api` and `./logs/indexer` (on the host) +to `/app/logs` (in each container) and sets `LOG_DIR=/app/logs`. With `LOG_DIR` +set, the service writes daily-rotated, human-readable files named +`.log.` into that directory, keeping at most `LOG_MAX_FILES` +of them (default 14): + ```sh +# tail the live stdout stream (unchanged) docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f api indexer + +# the persisted files on the host (survive container removal / redeploy) +tail -f logs/api/api.log.* +ls -1 logs/indexer/ ``` -Log verbosity is controlled by `RUST_LOG` (set in the override) and -`app.log_level` in config. +Notes: + +- The containers run as `root`, so files under `logs/` are root-owned — use + `sudo` to read/rotate them as a non-root user. +- `LOG_DIR` and `LOG_MAX_FILES` are environment variables (there is no YAML + config key). Unset `LOG_DIR` to disable file logging and keep stdout only. +- Verbosity is still controlled by `RUST_LOG` (set in the override) and + `app.log_level` in config; the same filter applies to stdout and files. ### Upgrades diff --git a/services/api/Cargo.toml b/services/api/Cargo.toml index c1170f08..3e3d9d51 100644 --- a/services/api/Cargo.toml +++ b/services/api/Cargo.toml @@ -19,6 +19,7 @@ uuid.workspace = true dodex-application = { path = "../../crates/application" } dodex-domain = { path = "../../crates/domain" } dodex-infrastructure = { path = "../../crates/infrastructure" } +dodex-logging = { path = "../../crates/logging" } [dev-dependencies] ackinacki-kit.workspace = true diff --git a/services/api/README.md b/services/api/README.md index e2e336ea..dc3f16b9 100644 --- a/services/api/README.md +++ b/services/api/README.md @@ -35,6 +35,11 @@ Config sections: `page_size` defaults to 100 and may be omitted; it is used by the indexer's paginated fetches but not by the API tier. +Logging is configured by environment variables, not YAML: `RUST_LOG` sets the +filter (default `info`), and `LOG_DIR` (optional) makes the service additionally +write daily-rotated `api.log.` files into that directory, retaining +`LOG_MAX_FILES` of them (default 14). See [docs/deployment.md](../../docs/deployment.md#logs). + The `auth.kek_hex` field is the 32-byte master key used to encrypt `api_secret` and `pn_seckey` at rest. `config/api.local.yaml` ships a shared dev value; stage and prod configs carry their own KEKs assembled diff --git a/services/api/src/lib.rs b/services/api/src/lib.rs index 6da0547e..26e67d4a 100644 --- a/services/api/src/lib.rs +++ b/services/api/src/lib.rs @@ -1752,12 +1752,9 @@ pub fn openapi_doc() -> OpenApi { /// stays a single line and every meaningful step is testable in /// isolation. pub async fn run() -> anyhow::Result<()> { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .init(); + // When LOG_DIR is set, these guards keep the background file-log writer + // alive for the lifetime of the process; `run()` serves until shutdown. + let _guards = dodex_logging::init("api"); let config_path = env::var("APP_CONFIG").unwrap_or_else(|_| "config/api.local.yaml".to_string()); diff --git a/services/indexer/Cargo.toml b/services/indexer/Cargo.toml index 9d0adc95..c96c5ed7 100644 --- a/services/indexer/Cargo.toml +++ b/services/indexer/Cargo.toml @@ -10,4 +10,5 @@ tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true dodex-infrastructure = { path = "../../crates/infrastructure" } +dodex-logging = { path = "../../crates/logging" } diff --git a/services/indexer/README.md b/services/indexer/README.md index ec48d071..75c68371 100644 --- a/services/indexer/README.md +++ b/services/indexer/README.md @@ -25,6 +25,11 @@ Config sections: - `graphql`: gateway endpoint, page size, request timeout. - `indexer`: polling/reconciliation/reprojection intervals and ignored addresses. +Logging is configured by environment variables, not YAML: `RUST_LOG` sets the +filter (default `info`), and `LOG_DIR` (optional) makes the service additionally +write daily-rotated `indexer.log.` files into that directory, retaining +`LOG_MAX_FILES` of them (default 14). See [docs/deployment.md](../../docs/deployment.md#logs). + ## Database The indexer applies SQL migrations from `migrations/` on startup. Column and diff --git a/services/indexer/src/main.rs b/services/indexer/src/main.rs index 2f2af2ed..92fea0b5 100644 --- a/services/indexer/src/main.rs +++ b/services/indexer/src/main.rs @@ -25,12 +25,9 @@ const MAX_PAGES_PER_TICK: u32 = 100; #[tokio::main] async fn main() -> anyhow::Result<()> { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .init(); + // When LOG_DIR is set, these guards keep the background file-log writer + // alive for the lifetime of the process; the indexer loops until shutdown. + let _guards = dodex_logging::init("indexer"); let config_path = env::var("APP_CONFIG").unwrap_or_else(|_| "config/indexer.local.yaml".to_string()); diff --git a/services/market-manager/Cargo.lock b/services/market-manager/Cargo.lock index f2556a4d..987d8e44 100644 --- a/services/market-manager/Cargo.lock +++ b/services/market-manager/Cargo.lock @@ -1456,6 +1456,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "dodex-logging" +version = "0.1.0" +dependencies = [ + "anyhow", + "tracing", + "tracing-appender", + "tracing-subscriber 0.3.23", +] + [[package]] name = "dodex-market-manager" version = "0.1.0" @@ -1463,6 +1473,7 @@ dependencies = [ "ackinacki-kit", "anyhow", "dodex-chain", + "dodex-logging", "num-bigint 0.4.6", "rand 0.8.6", "serde", @@ -4185,6 +4196,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -4557,6 +4574,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber 0.3.23", +] + [[package]] name = "tracing-attributes" version = "0.1.31" diff --git a/services/market-manager/Cargo.toml b/services/market-manager/Cargo.toml index 96640615..0b108013 100644 --- a/services/market-manager/Cargo.toml +++ b/services/market-manager/Cargo.toml @@ -26,3 +26,4 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } ackinacki-kit = { git = "https://github.com/gosh-sh/ackinacki-kit.git", branch = "feature/update_dex", default-features = false, features = ["contracts", "default"] } dodex-chain = { path = "../../crates/chain", features = ["test-helpers"] } +dodex-logging = { path = "../../crates/logging" } diff --git a/services/market-manager/Dockerfile b/services/market-manager/Dockerfile index 60630c0e..e5b8289b 100644 --- a/services/market-manager/Dockerfile +++ b/services/market-manager/Dockerfile @@ -4,6 +4,7 @@ WORKDIR /app COPY services/market-manager/Cargo.toml services/market-manager/Cargo.lock* ./ COPY services/market-manager/src ./src COPY crates/chain /crates/chain +COPY crates/logging /crates/logging RUN cargo build --release diff --git a/services/market-manager/src/main.rs b/services/market-manager/src/main.rs index 5bbb9465..b762ad6e 100644 --- a/services/market-manager/src/main.rs +++ b/services/market-manager/src/main.rs @@ -898,12 +898,9 @@ fn build_client_context(endpoint: &str) -> Result> { #[tokio::main] async fn main() -> Result<()> { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .init(); + // When LOG_DIR is set, these guards keep the background file-log writer + // alive for the lifetime of the process; the manager loops until shutdown. + let _guards = dodex_logging::init("market-manager"); let config_path = env::var("APP_CONFIG").unwrap_or_else(|_| "config/market-manager.stage.yaml".to_string());