Skip to main content

decdn_node/commands/
mod.rs

1//! Daemon entry point: `commands::run` parses the resolved config and
2//! hands off to [`crate::runtime::run`].
3//!
4//! User-facing subcommands (probe, node admin, key-gen, config) live in
5//! `crates/cli/`. This module retains only the `run` function and the
6//! tracing/OTLP bring-up the daemon needs at start-up — pulling them
7//! out into a separate file mostly avoids churn in
8//! `crates/node/src/main.rs`, which stays a thin clap shell.
9
10mod otlp;
11
12use std::sync::Arc;
13
14use decdn_common::{cli, config, identity};
15use opentelemetry_sdk::trace::SdkTracerProvider;
16use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields, MakeWriter, format};
17use tracing_subscriber::registry::LookupSpan;
18
19use crate::{metrics, runtime};
20
21/// Run the deCDN node with resolved configuration.
22pub async fn run(
23    config_path: Option<&std::path::Path>,
24    run_args: &cli::RunArgs,
25) -> anyhow::Result<()> {
26    let (resolved, notices) = config::resolve_config(config_path, run_args)?;
27
28    let (filter, rust_log_pinned) = startup_log_filter(&resolved);
29
30    // Built before tracing so the OTLP exporter counts into the registry
31    // `/metrics` serves.
32    let node_metrics = Arc::new(metrics::Metrics::new());
33
34    // Before the key load: a bad endpoint fails the start before a first
35    // start writes a key whose "generated" line would then never be logged.
36    let tracer_provider = resolved
37        .observability
38        .otlp_endpoint
39        .as_deref()
40        .map(|endpoint| otlp::init_otlp_provider(endpoint, Arc::clone(&node_metrics)))
41        .transpose()?;
42
43    // Before `init_tracing`: the JSON formatter writes this key's `node_id`
44    // on every event, the first one included. A load failure returns to
45    // `main`, which prints it to stderr like a config error.
46    let node_key = identity::load_or_create(&resolved.identity.data_dir)?;
47    let node_id = node_key.key.public();
48
49    let log_level_setter = init_tracing(
50        filter,
51        rust_log_pinned,
52        resolved.observability.log_format,
53        &node_id,
54        tracer_provider.as_ref(),
55    );
56
57    // Replay what `resolve_config` recorded. It runs before `init_tracing`
58    // (the fallback filter above is built from the resolved log level), so a
59    // resolver cannot emit these itself — it hands them back and this is the
60    // first point at which a subscriber exists to receive them.
61    runtime::emit_config_notices(&notices);
62
63    tracing::info!("deCDN node starting");
64    log_node_key(&node_key, &resolved.identity.data_dir);
65    tracing::debug!(
66        data_dir = %resolved.identity.data_dir.display(),
67        bind_port = resolved.network.bind_port,
68        rpc_url = "<redacted>",
69        cache_dir = %resolved.cache.cache_dir.display(),
70        cache_size_mb = resolved.cache.cache_size_mb,
71        rate_per_mb = resolved.payment.rate_per_mb,
72        metrics_port = resolved.observability.metrics_port,
73        "resolved configuration"
74    );
75
76    let reload_state = Arc::new(runtime::RuntimeReloadState::new(
77        run_args.observability.clone(),
78        &resolved,
79        log_level_setter,
80    ));
81
82    // `Box::pin` the runtime future: its state machine crosses clippy's
83    // `large_futures` threshold. It runs once per process, so the heap
84    // allocation costs nothing that matters.
85    let result = Box::pin(runtime::run(
86        resolved,
87        node_key.key,
88        config_path.map(std::path::Path::to_path_buf),
89        reload_state,
90        node_metrics,
91    ))
92    .await;
93
94    // On both exit paths: the spans around a failed run are the ones an
95    // operator most wants exported.
96    if let Some(provider) = tracer_provider {
97        otlp::finish_run(provider, &result).await;
98    }
99
100    result
101}
102
103/// Log the node id from [`identity::load_or_create`], and the key path when
104/// that call generated the key. The load runs before the subscriber exists,
105/// so it cannot log for itself.
106fn log_node_key(node_key: &identity::LoadedKey, data_dir: &std::path::Path) {
107    if node_key.generated {
108        tracing::info!(
109            path = %identity::key_path(data_dir).display(),
110            "generated new node secret key"
111        );
112    }
113    tracing::info!(node_id = %node_key.key.public(), "loaded node identity");
114}
115
116/// The startup log filter, and whether it came from `RUST_LOG`.
117///
118/// `RUST_LOG` takes precedence over the resolved log level, for the whole life
119/// of the process (see [`reload_directive`]). A malformed `RUST_LOG` falls back
120/// to the resolved level with a warning on stderr.
121#[expect(
122    clippy::print_stderr,
123    reason = "runs before the tracing subscriber is initialized"
124)]
125fn startup_log_filter(resolved: &config::ResolvedConfig) -> (tracing_subscriber::EnvFilter, bool) {
126    match tracing_subscriber::EnvFilter::try_from_default_env() {
127        Ok(f) => (f, true),
128        Err(e) => {
129            // Only warn if RUST_LOG was actually set (not just absent).
130            if std::env::var_os("RUST_LOG").is_some() {
131                eprintln!("warning: ignoring malformed RUST_LOG: {e}");
132            }
133            (
134                tracing_subscriber::EnvFilter::new(resolved.observability.log_level.to_string()),
135                false,
136            )
137        }
138    }
139}
140
141/// Initialize the tracing subscriber with a fmt layer for `log_format`, plus
142/// an OTLP span export layer through `provider` when export is on. In the
143/// JSON log format every event carries `node_id` (see [`NodeIdJson`]).
144///
145/// Returns a [`runtime::LogLevelSetter`] closure that swaps the live
146/// `EnvFilter` to one matching a new `LogLevel` — used by the SIGHUP
147/// hot-reload path (#236). When `rust_log_pinned` is set, the filter came from
148/// `RUST_LOG` and the closure leaves it in place (see [`reload_directive`]).
149/// The closure captures a `reload::Handle` to the fmt layer's per-layer
150/// `EnvFilter` (see [`subscriber`]); calls to `modify` must respect any errors
151/// from the handle (e.g. the registry was dropped) by surfacing them.
152fn init_tracing(
153    filter: tracing_subscriber::EnvFilter,
154    rust_log_pinned: bool,
155    log_format: cli::LogFormat,
156    node_id: &iroh::PublicKey,
157    provider: Option<&SdkTracerProvider>,
158) -> runtime::LogLevelSetter {
159    use tracing_subscriber::prelude::*;
160
161    let fmt_layer = fmt_layer(log_format, node_id, std::io::stdout);
162
163    // Wrap the EnvFilter in a `reload::Layer` so the SIGHUP reload path
164    // can swap it without rebuilding the rest of the subscriber stack.
165    let (reload_filter, reload_handle) = tracing_subscriber::reload::Layer::new(filter);
166
167    subscriber(fmt_layer, reload_filter, provider).init();
168
169    Box::new(move |lvl| {
170        let Some(directive) = reload_directive(rust_log_pinned, lvl) else {
171            tracing::info!(
172                log_level = %lvl,
173                "RUST_LOG is set; the config file log_level is not applied"
174            );
175            return Ok(runtime::LogLevelApply::KeptRustLog);
176        };
177        let new_filter = tracing_subscriber::EnvFilter::try_new(directive)
178            .map_err(|e| anyhow::anyhow!("invalid log_level {lvl}: {e}"))?;
179        reload_handle
180            .modify(|f| *f = new_filter)
181            .map_err(|e| anyhow::anyhow!("failed to swap tracing filter: {e}"))?;
182        Ok(runtime::LogLevelApply::Installed)
183    })
184}
185
186/// The fmt layer for `format`, writing to `writer`. The JSON format carries
187/// `node_id` on every event; the pretty format is for a terminal and does not.
188fn fmt_layer<W>(
189    format: cli::LogFormat,
190    node_id: &iroh::PublicKey,
191    writer: W,
192) -> Box<dyn tracing_subscriber::Layer<tracing_subscriber::Registry> + Send + Sync>
193where
194    W: for<'w> MakeWriter<'w> + Send + Sync + 'static,
195{
196    use tracing_subscriber::prelude::*;
197
198    let layer = tracing_subscriber::fmt::layer().with_writer(writer);
199    match format {
200        cli::LogFormat::Json => layer
201            .json()
202            .map_event_format(|inner| NodeIdJson::new(inner, node_id))
203            .boxed(),
204        cli::LogFormat::Pretty => layer.boxed(),
205    }
206}
207
208/// A JSON event formatter that writes `node_id` as the first top-level key of
209/// every event, beside `timestamp`, `level`, `target` and `fields`.
210///
211/// Logs reach the aggregator with host labels only. This key joins a log line
212/// to the iroh `NodeId` that peers and the chain see.
213///
214/// The inner formatter must keep `fields` nested. `flatten_event(true)` lifts
215/// event fields to the top level, so an event with its own `node_id` field
216/// would then write the key twice.
217struct NodeIdJson<F> {
218    inner: F,
219    /// The opening of every event: `{"node_id":<JSON string>,`.
220    prefix: String,
221}
222
223impl<F> NodeIdJson<F> {
224    fn new(inner: F, node_id: &iroh::PublicKey) -> Self {
225        let id = serde_json::Value::from(node_id.to_string());
226        Self {
227            inner,
228            prefix: format!("{{\"node_id\":{id},"),
229        }
230    }
231}
232
233impl<S, N, F> FormatEvent<S, N> for NodeIdJson<F>
234where
235    S: tracing::Subscriber + for<'a> LookupSpan<'a>,
236    N: for<'a> FormatFields<'a> + 'static,
237    F: FormatEvent<S, N>,
238{
239    fn format_event(
240        &self,
241        ctx: &FmtContext<'_, S, N>,
242        mut writer: format::Writer<'_>,
243        event: &tracing::Event<'_>,
244    ) -> std::fmt::Result {
245        let mut line = String::new();
246        self.inner
247            .format_event(ctx, format::Writer::new(&mut line), event)?;
248        // The inner formatter writes an unflattened `fields` object on every
249        // event, so the object after `{` is never empty and the prefix's
250        // trailing comma is valid.
251        let rest = line.strip_prefix('{').ok_or(std::fmt::Error)?;
252        writer.write_str(&self.prefix)?;
253        writer.write_str(rest)
254    }
255}
256
257/// The fmt layer's log filter behind a reload handle.
258type LogFilter =
259    tracing_subscriber::reload::Layer<tracing_subscriber::EnvFilter, tracing_subscriber::Registry>;
260
261/// The node's subscriber stack: `fmt_layer` filtered by `log_filter` alone,
262/// plus span export through `provider` when OTLP is on.
263///
264/// The log filter is a per-layer filter on the fmt layer, never a global one:
265/// span export has its own fixed filter (`otlp::otel_layer`), so lowering the
266/// log level never drops traces.
267fn subscriber(
268    fmt_layer: Box<dyn tracing_subscriber::Layer<tracing_subscriber::Registry> + Send + Sync>,
269    log_filter: LogFilter,
270    provider: Option<&SdkTracerProvider>,
271) -> impl tracing::Subscriber + Send + Sync + for<'span> tracing_subscriber::registry::LookupSpan<'span>
272{
273    use tracing_subscriber::prelude::*;
274
275    tracing_subscriber::registry()
276        .with(fmt_layer.with_filter(log_filter))
277        .with(provider.map(otlp::otel_layer))
278}
279
280/// The filter directive a config-file `log_level` reload installs, or `None`
281/// when the live filter must stay as it is.
282///
283/// `RUST_LOG` wins over the file `log_level` at startup, so it wins on every
284/// reload too: a reload that replaced a `RUST_LOG` filter with a bare level
285/// would silently drop its per-target directives. Without a valid `RUST_LOG`, the
286/// directive is the level's lowercase name, the same construction as the
287/// startup fallback.
288fn reload_directive(rust_log_pinned: bool, lvl: cli::common::LogLevel) -> Option<String> {
289    (!rust_log_pinned).then(|| lvl.to_string())
290}
291
292#[cfg(test)]
293mod tests;