Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
internal-docs/
target/
worktrees/
# Host-mounted service logs (docker-compose bind mounts ./logs/<service>).
logs/
.claude/roles/
.claude/commands.txt
.claude/skills/reviewer-core/
Expand Down
32 changes: 32 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<service>`. See
[docs/deployment.md](docs/deployment.md#logs).

Secrets and environment-specific values live in `.env`:

```sh
Expand Down
18 changes: 18 additions & 0 deletions crates/logging/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
123 changes: 123 additions & 0 deletions crates/logging/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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 `<service>.log.<date>`
//! 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<RollingFileAppender> {
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 `<service>.log.<date>` 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<WorkerGuard> {
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}");
}
}
2 changes: 2 additions & 0 deletions docker-compose.stage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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
28 changes: 24 additions & 4 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down Expand Up @@ -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
`<service>.log.<YYYY-MM-DD>` 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

Expand Down
1 change: 1 addition & 0 deletions services/api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions services/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<date>` 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
Expand Down
9 changes: 3 additions & 6 deletions services/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
1 change: 1 addition & 0 deletions services/indexer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
dodex-infrastructure = { path = "../../crates/infrastructure" }
dodex-logging = { path = "../../crates/logging" }

5 changes: 5 additions & 0 deletions services/indexer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<date>` 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
Expand Down
9 changes: 3 additions & 6 deletions services/indexer/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Loading
Loading