Skip to content

Commit 1418b03

Browse files
authored
Feature/node 3548 redeploy dodex backend with mounted logs (#47)
1 parent 1d5e8f5 commit 1418b03

19 files changed

Lines changed: 264 additions & 22 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
internal-docs/
44
target/
55
worktrees/
6+
# Host-mounted service logs (docker-compose bind mounts ./logs/<service>).
7+
logs/
68
.claude/roles/
79
.claude/commands.txt
810
.claude/skills/reviewer-core/

Cargo.lock

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ members = [
66
"crates/domain",
77
"crates/application",
88
"crates/infrastructure",
9+
"crates/logging",
910
]
1011
# `sdk/` is its own workspace, kept out of the root resolve on purpose: its
1112
# halo2 proof pipeline pulls a heavy, distinct zk/halo2 dependency graph.

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ Per-service config files live under `config/`:
4646

4747
Local defaults: `config/api.local.yaml`, `config/indexer.local.yaml`. Override at runtime with `APP_CONFIG=/path/to/file.yaml`.
4848

49+
Logging is environment-driven: `RUST_LOG` sets verbosity, and `LOG_DIR`
50+
(optional) makes each service also write rotated log files into a directory —
51+
the Compose deployment bind-mounts these to `./logs/<service>`. See
52+
[docs/deployment.md](docs/deployment.md#logs).
53+
4954
Secrets and environment-specific values live in `.env`:
5055

5156
```sh

crates/logging/Cargo.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "dodex-logging"
3+
version = "0.1.0"
4+
edition = "2024"
5+
license = "LicenseRef-Acki-Nacki-Node-License"
6+
7+
# Inline rather than `.workspace = true` so this crate can be consumed from
8+
# `services/market-manager` (its own Cargo workspace) and from inside the
9+
# market-manager Docker build, neither of which can reach the parent
10+
# workspace's `[workspace.dependencies]`. Same rationale as `crates/chain`.
11+
[dependencies]
12+
anyhow = "1.0"
13+
tracing = "0.1"
14+
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
15+
tracing-appender = "0.2"
16+
17+
[dev-dependencies]
18+
tempfile = "3"

crates/logging/src/lib.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// 2026 (c) Copyright Contributors to the GOSH DAO. All rights reserved.
2+
//
3+
4+
//! Shared tracing setup for the dodex services.
5+
//!
6+
//! Every service logs to stdout, filtered by `RUST_LOG` (default `info`).
7+
//! When the `LOG_DIR` environment variable is set and non-empty, each service
8+
//! ALSO writes human-readable, daily-rotated files named `<service>.log.<date>`
9+
//! into that directory, keeping at most `LOG_MAX_FILES` (default 14) of them.
10+
//!
11+
//! Lives in its own crate — free of the heavy `dodex-infrastructure`
12+
//! dependency graph — so the standalone `market-manager` workspace can reuse
13+
//! it by path, exactly like `dodex-chain`.
14+
15+
use std::env;
16+
17+
use tracing_appender::non_blocking::WorkerGuard;
18+
use tracing_appender::rolling::RollingFileAppender;
19+
use tracing_appender::rolling::Rotation;
20+
use tracing_subscriber::fmt;
21+
use tracing_subscriber::layer::SubscriberExt;
22+
use tracing_subscriber::util::SubscriberInitExt;
23+
use tracing_subscriber::EnvFilter;
24+
25+
/// Daily log files retained before the oldest is pruned, when `LOG_MAX_FILES`
26+
/// is unset or unparseable.
27+
const DEFAULT_MAX_FILES: usize = 14;
28+
29+
fn env_filter() -> EnvFilter {
30+
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))
31+
}
32+
33+
fn max_files() -> usize {
34+
env::var("LOG_MAX_FILES").ok().and_then(|v| v.parse().ok()).unwrap_or(DEFAULT_MAX_FILES)
35+
}
36+
37+
/// Build the daily rolling-file appender for `service` under `dir`, creating
38+
/// the directory if needed. Errors out (rather than panicking) so the caller
39+
/// can fall back to stdout-only.
40+
fn file_appender(dir: &str, service: &str) -> anyhow::Result<RollingFileAppender> {
41+
std::fs::create_dir_all(dir)?;
42+
let appender = RollingFileAppender::builder()
43+
.rotation(Rotation::DAILY)
44+
.filename_prefix(format!("{service}.log"))
45+
.max_log_files(max_files())
46+
.build(dir)?;
47+
Ok(appender)
48+
}
49+
50+
/// Install the global tracing subscriber for `service`.
51+
///
52+
/// Always logs to stdout. If `LOG_DIR` is set and non-empty, also writes
53+
/// daily-rotated `<service>.log.<date>` files there. On a log-dir error, warns
54+
/// loudly to the (already-installed) stdout logger and continues stdout-only.
55+
///
56+
/// Returns the appender guard(s). The caller MUST keep them alive for the
57+
/// lifetime of the process (`let _guards = dodex_logging::init("api");`) — drop
58+
/// them and the background file writer stops flushing.
59+
#[must_use]
60+
pub fn init(service: &str) -> Vec<WorkerGuard> {
61+
let stdout_layer = fmt::layer().with_writer(std::io::stdout);
62+
63+
let log_dir = env::var("LOG_DIR").unwrap_or_default();
64+
if log_dir.is_empty() {
65+
tracing_subscriber::registry().with(env_filter()).with(stdout_layer).init();
66+
return Vec::new();
67+
}
68+
69+
match file_appender(&log_dir, service) {
70+
Ok(appender) => {
71+
let (writer, guard) = tracing_appender::non_blocking(appender);
72+
let file_layer = fmt::layer().with_ansi(false).with_writer(writer);
73+
tracing_subscriber::registry()
74+
.with(env_filter())
75+
.with(stdout_layer)
76+
.with(file_layer)
77+
.init();
78+
vec![guard]
79+
}
80+
Err(err) => {
81+
tracing_subscriber::registry().with(env_filter()).with(stdout_layer).init();
82+
tracing::warn!(
83+
log_dir = %log_dir,
84+
error = %err,
85+
"LOG_DIR set but file logging could not be initialised; continuing with stdout only"
86+
);
87+
Vec::new()
88+
}
89+
}
90+
}
91+
92+
#[cfg(test)]
93+
mod tests {
94+
use super::*;
95+
96+
#[test]
97+
fn writes_a_rotated_file_when_dir_is_set() {
98+
let dir = tempfile::tempdir().expect("tempdir");
99+
let path = dir.path().to_str().expect("utf-8 path");
100+
101+
let appender = file_appender(path, "testsvc").expect("appender builds");
102+
let (writer, guard) = tracing_appender::non_blocking(appender);
103+
let subscriber =
104+
tracing_subscriber::registry().with(fmt::layer().with_ansi(false).with_writer(writer));
105+
106+
tracing::subscriber::with_default(subscriber, || {
107+
tracing::info!("hello-from-test");
108+
});
109+
110+
// Dropping the guard flushes and joins the background writer thread.
111+
drop(guard);
112+
113+
let log_files: Vec<_> = std::fs::read_dir(dir.path())
114+
.expect("read tempdir")
115+
.filter_map(Result::ok)
116+
.filter(|e| e.file_name().to_string_lossy().starts_with("testsvc.log"))
117+
.collect();
118+
assert_eq!(log_files.len(), 1, "exactly one rotated file is created");
119+
120+
let contents = std::fs::read_to_string(log_files[0].path()).expect("read log file");
121+
assert!(contents.contains("hello-from-test"), "log line written, got: {contents}");
122+
}
123+
}

docker-compose.stage.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ services:
1616
environment:
1717
APP_CONFIG: /app/config/market-manager.stage.yaml
1818
RUST_LOG: info
19+
LOG_DIR: /app/logs
1920
volumes:
2021
# Mount config dir read-only so YAML / events / secrets edits take
2122
# effect on container restart without rebuilding the image.
2223
- ./services/market-manager/config:/app/config:ro
2324
- market-manager-state:/state
25+
- ./logs/market-manager:/app/logs
2426
restart: unless-stopped
2527

2628
volumes:

docker-compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ services:
66
environment:
77
APP_CONFIG: /app/config/api.local.yaml
88
RUST_LOG: info
9+
LOG_DIR: /app/logs
910
ports:
1011
- "8080:8080"
1112
volumes:
1213
- ./config:/app/config:ro
14+
- ./logs/api:/app/logs
1315
restart: unless-stopped
1416
healthcheck:
1517
# Probe the running API rather than spawning a second `dodex-api`
@@ -28,6 +30,8 @@ services:
2830
environment:
2931
APP_CONFIG: /app/config/indexer.local.yaml
3032
RUST_LOG: info
33+
LOG_DIR: /app/logs
3134
volumes:
3235
- ./config:/app/config:ro
36+
- ./logs/indexer:/app/logs
3337
restart: unless-stopped

docs/deployment.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,10 @@ api only:
252252
## Step 4 — Compose override, build, and run
253253

254254
The base `docker-compose.yml` mounts `./config` into each container read-only
255-
(`/app/config`) and defaults `APP_CONFIG` to the `*.local.yaml` files. Add an
256-
override that points `APP_CONFIG` at your own files — mirroring how
255+
(`/app/config`), bind-mounts a per-service host log directory
256+
(`./logs/api`, `./logs/indexer` → `/app/logs`) with `LOG_DIR=/app/logs` set,
257+
and defaults `APP_CONFIG` to the `*.local.yaml` files. Add an override that
258+
points `APP_CONFIG` at your own files — mirroring how
257259
`docker-compose.stage.yml` selects the Supabase configs.
258260

259261
Create `docker-compose.prod.yml`:
@@ -327,12 +329,30 @@ docker compose -f docker-compose.yml -f docker-compose.prod.yml kill -s SIGUSR1
327329

328330
### Logs
329331

332+
Each service writes to **both** stdout and a host-mounted directory. The base
333+
`docker-compose.yml` bind-mounts `./logs/api` and `./logs/indexer` (on the host)
334+
to `/app/logs` (in each container) and sets `LOG_DIR=/app/logs`. With `LOG_DIR`
335+
set, the service writes daily-rotated, human-readable files named
336+
`<service>.log.<YYYY-MM-DD>` into that directory, keeping at most `LOG_MAX_FILES`
337+
of them (default 14):
338+
330339
```sh
340+
# tail the live stdout stream (unchanged)
331341
docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f api indexer
342+
343+
# the persisted files on the host (survive container removal / redeploy)
344+
tail -f logs/api/api.log.*
345+
ls -1 logs/indexer/
332346
```
333347

334-
Log verbosity is controlled by `RUST_LOG` (set in the override) and
335-
`app.log_level` in config.
348+
Notes:
349+
350+
- The containers run as `root`, so files under `logs/` are root-owned — use
351+
`sudo` to read/rotate them as a non-root user.
352+
- `LOG_DIR` and `LOG_MAX_FILES` are environment variables (there is no YAML
353+
config key). Unset `LOG_DIR` to disable file logging and keep stdout only.
354+
- Verbosity is still controlled by `RUST_LOG` (set in the override) and
355+
`app.log_level` in config; the same filter applies to stdout and files.
336356

337357
### Upgrades
338358

services/api/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ uuid.workspace = true
1919
dodex-application = { path = "../../crates/application" }
2020
dodex-domain = { path = "../../crates/domain" }
2121
dodex-infrastructure = { path = "../../crates/infrastructure" }
22+
dodex-logging = { path = "../../crates/logging" }
2223

2324
[dev-dependencies]
2425
ackinacki-kit.workspace = true

0 commit comments

Comments
 (0)