Skip to main content

saluki_app/logging/
api.rs

1use std::{pin::Pin, sync::Arc, time::Duration};
2
3use async_trait::async_trait;
4use futures::{Stream, StreamExt as _};
5use saluki_api::{
6    extract::{Query, State},
7    response::IntoResponse,
8    routing::{post, Router},
9    APIHandler, DynamicRoute, EndpointType, StatusCode,
10};
11use saluki_common::{logging::parse_filter_directives, sync::shutdown::ShutdownHandle};
12use saluki_core::runtime::{state::DataspaceRegistry, InitializationError, Supervisable, SupervisorFuture};
13use saluki_error::{generic_error, GenericError};
14use serde::Deserialize;
15use tokio::{
16    pin, select,
17    sync::{mpsc, Mutex},
18    time::sleep,
19};
20use tracing::{debug, error, info, warn};
21use tracing_subscriber::{filter::Targets, reload::Handle, Registry};
22
23use super::LogLevel;
24
25#[derive(Deserialize)]
26struct OverrideQueryParams {
27    time_secs: u64,
28}
29
30/// An action driven through a [`LoggingOverrideController`] and processed by [`LoggingOverrideWorker`].
31enum LoggingOverrideAction {
32    /// Apply `filter` as a temporary override on top of the current base, lasting `duration`.
33    ///
34    /// When the duration elapses, or a [`Reset`][LoggingOverrideAction::Reset] arrives, the worker restores the
35    /// current base filter.
36    Override { duration: Duration, filter: Targets },
37
38    /// Clear any active override, immediately restoring the current base filter.
39    Reset,
40
41    /// Replace the current base filter.
42    ///
43    /// Applied immediately if no override is active. If an override is active, the new base is stored and applied
44    /// once the override expires (or is reset), so we don't clobber an in-flight override.
45    UpdateBase(Targets),
46}
47
48/// Controls the dynamic logging filter at runtime.
49///
50/// All filter mutations—temporary overrides, resets, and base updates—flow through here to the
51/// [`LoggingOverrideWorker`] that owns the underlying `tracing` reload handle. Cloning is cheap; hand clones to
52/// any caller that needs to drive filter changes (HTTP handlers, configuration watchers, etc.).
53#[derive(Clone)]
54pub struct LoggingOverrideController {
55    tx: mpsc::Sender<LoggingOverrideAction>,
56}
57
58impl LoggingOverrideController {
59    /// Applies `filter` as a temporary override for `duration`, after which the base filter is restored.
60    pub(crate) async fn override_for(&self, duration: Duration, filter: Targets) -> Result<(), GenericError> {
61        self.send(LoggingOverrideAction::Override { duration, filter }).await
62    }
63
64    /// Clears any active override, immediately restoring the current base filter.
65    pub(crate) async fn reset(&self) -> Result<(), GenericError> {
66        self.send(LoggingOverrideAction::Reset).await
67    }
68
69    /// Replaces the current base filter.
70    ///
71    /// Applied immediately if no override is active; otherwise stored and applied when the override expires.
72    pub async fn update_base(&self, filter: Targets) -> Result<(), GenericError> {
73        self.send(LoggingOverrideAction::UpdateBase(filter)).await
74    }
75
76    async fn send(&self, action: LoggingOverrideAction) -> Result<(), GenericError> {
77        self.tx
78            .send(action)
79            .await
80            .map_err(|_| generic_error!("logging override worker is no longer running"))
81    }
82}
83
84/// State used for the logging API handler.
85#[derive(Clone)]
86pub struct LoggingHandlerState {
87    controller: LoggingOverrideController,
88}
89
90/// An API handler for updating log filtering directives at runtime.
91///
92/// This handler exposes two main routes -- `/logging/override` and `/logging/reset` -- which allow for overriding the
93/// default log filtering directives (configured at startup) at runtime, and then resetting them once the override is no
94/// longer needed.
95///
96/// As this has the potential for incredibly verbose logging at runtime, the override is set with a specific duration in
97/// which it will apply. Once an override has been active for the configured duration, it will automatically be reset
98/// unless the override is refreshed before the duration elapses.
99///
100/// The maximum duration for an override is 10 minutes.
101pub struct LoggingAPIHandler {
102    state: LoggingHandlerState,
103}
104
105impl LoggingAPIHandler {
106    /// Creates a new `LoggingAPIHandler` driven by the given controller.
107    fn new(controller: LoggingOverrideController) -> Self {
108        Self {
109            state: LoggingHandlerState { controller },
110        }
111    }
112
113    async fn override_handler(
114        State(state): State<LoggingHandlerState>, params: Query<OverrideQueryParams>, body: String,
115    ) -> impl IntoResponse {
116        // Make sure the override length is within the acceptable range.
117        const MAXIMUM_OVERRIDE_LENGTH_SECS: u64 = 600;
118        if params.time_secs > MAXIMUM_OVERRIDE_LENGTH_SECS {
119            return (
120                StatusCode::BAD_REQUEST,
121                format!(
122                    "override time cannot be greater than {} seconds",
123                    MAXIMUM_OVERRIDE_LENGTH_SECS
124                ),
125            );
126        }
127
128        // Parse the override duration and create a new filter from the body.
129        let duration = Duration::from_secs(params.time_secs);
130        let new_filter = match parse_filter_directives(&body) {
131            Ok(filter) => filter,
132            Err(e) => {
133                return (
134                    StatusCode::BAD_REQUEST,
135                    format!("failed to parse override filter: {}", e),
136                )
137            }
138        };
139
140        // Instruct the override worker to apply the new log filtering directives for the given duration.
141        let _ = state.controller.override_for(duration, new_filter).await;
142
143        (StatusCode::OK, "acknowledged".to_string())
144    }
145
146    async fn reset_handler(State(state): State<LoggingHandlerState>) {
147        // Instruct the override worker to immediately reset back to the current base log filtering directives.
148        let _ = state.controller.reset().await;
149    }
150}
151
152impl APIHandler for LoggingAPIHandler {
153    type State = LoggingHandlerState;
154
155    fn generate_initial_state(&self) -> Self::State {
156        self.state.clone()
157    }
158
159    fn generate_routes(&self) -> Router<Self::State> {
160        Router::new()
161            .route("/logging/override", post(Self::override_handler))
162            .route("/logging/reset", post(Self::reset_handler))
163    }
164}
165
166/// A worker that processes log filter directive overrides.
167///
168/// When running, the worker asserts a set of routes (based on [`LoggingAPIHandler`]) that allow triggering
169/// an override of the current logging filter directives as well as clearing (resetting) the active override.
170pub struct LoggingOverrideWorker {
171    handler: LoggingAPIHandler,
172    state: Arc<Mutex<LoggingOverrideWorkerState>>,
173}
174
175struct LoggingOverrideWorkerState {
176    reload_handle: Handle<Targets, Registry>,
177    rx: mpsc::Receiver<LoggingOverrideAction>,
178}
179
180impl LoggingOverrideWorker {
181    /// Creates a new `LoggingOverrideWorker` driving the given reload handle.
182    ///
183    /// When the worker starts, the filter directives present in the reload handle will be used as the "base" filter:
184    /// the filter that's reapplied after an override expires or is reset. This base filter can then be subsequently
185    /// updated through the [`LoggingOverrideController`] handle that's returned.
186    pub(super) fn new(reload_handle: Handle<Targets, Registry>) -> (Self, LoggingOverrideController) {
187        let (tx, rx) = mpsc::channel(1);
188        let controller = LoggingOverrideController { tx };
189        let handler = LoggingAPIHandler::new(controller.clone());
190        let worker = Self {
191            handler,
192            state: Arc::new(Mutex::new(LoggingOverrideWorkerState { reload_handle, rx })),
193        };
194
195        (worker, controller)
196    }
197}
198
199#[async_trait]
200impl Supervisable for LoggingOverrideWorker {
201    fn name(&self) -> &str {
202        "level-override-processor"
203    }
204
205    async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
206        let mut state = self.state.clone().lock_owned().await;
207        let logging_route = DynamicRoute::http(EndpointType::Privileged, &self.handler);
208
209        Ok(Box::pin(async move {
210            DataspaceRegistry::try_current()
211                .ok_or_else(|| generic_error!("Dataspace not available."))?
212                .assert(logging_route, "logging-api");
213
214            process_override_actions(&mut state, process_shutdown).await;
215            Ok(())
216        }))
217    }
218}
219
220async fn process_override_actions(state: &mut LoggingOverrideWorkerState, process_shutdown: ShutdownHandle) {
221    // Seed the canonical base filter from the reload handle. If the underlying reload layer has been dropped, we
222    // cannot perform overrides or resets meaningfully: just emit an error and wait for shutdown so that we don't
223    // exit prematurely and drive the supervisor into an infinite restart loop.
224    let mut base_filter = match state.reload_handle.clone_current() {
225        Some(filter) => filter,
226        None => {
227            error!("Logging subsystem is in an indeterminate state; dynamic log filtering will not be available.");
228
229            process_shutdown.await;
230            return;
231        }
232    };
233
234    let mut override_active = false;
235    let override_timeout = sleep(Duration::from_secs(3600));
236
237    pin!(override_timeout, process_shutdown);
238
239    loop {
240        select! {
241            _ = &mut process_shutdown => break,
242            maybe_action = state.rx.recv() => match maybe_action {
243                Some(LoggingOverrideAction::Override { duration, filter }) => {
244                    info!(directives = %filter, "Overriding existing log filtering directives for {} seconds...", duration.as_secs());
245
246                    match state.reload_handle.reload(filter) {
247                        Ok(()) => {
248                            // We were able to successfully reload the filter, so mark ourselves as having an active
249                            // override and update the override timeout.
250                            override_active = true;
251                            override_timeout.as_mut().reset(tokio::time::Instant::now() + duration);
252                        },
253                        Err(e) => error!(error = %e, "Failed to override log filtering directives."),
254                    }
255                },
256
257                Some(LoggingOverrideAction::Reset) => {
258                    // We've been instructed to immediately reset the filter back to the base one, so simply update
259                    // the override timeout to fire as soon as possible.
260                    override_timeout.as_mut().reset(tokio::time::Instant::now());
261                },
262
263                Some(LoggingOverrideAction::UpdateBase(new_base)) => {
264                    // Before replacing the base filter, check if the new base is different from the current one before we trigger a reload
265                    // and log a big, noisy message.
266                    if new_base == base_filter {
267                        continue;
268                    }
269
270                    base_filter = new_base;
271
272                    if override_active {
273                        // An override is currently active. Don't clobber it -- the new base will take effect when
274                        // the override expires.
275                        info!(
276                            directives = %base_filter,
277                            "Updated base log filtering directives; application deferred until active override expires."
278                        );
279                    } else {
280                        let new_directives = base_filter.to_string();
281                        match state.reload_handle.reload(base_filter.clone()) {
282                            Ok(()) => info!(directives = %new_directives, "Updated base log filtering directives."),
283                            Err(e) => error!(error = %e, "Failed to update base log filtering directives."),
284                        }
285                    }
286                },
287
288                // Our sender has dropped, so there's no more actions for us to handle.
289                None => break,
290            },
291            _ = &mut override_timeout => {
292                // Our override timeout has fired. If we have an active override, reset it back to the base filter.
293                //
294                // Otherwise, this just means that we've been running for a while without any overrides, so we can just
295                // reset the timeout with a long duration.
296                if override_active {
297                    override_active = false;
298
299                    let restore_directives = base_filter.to_string();
300                    if let Err(e) = state.reload_handle.reload(base_filter.clone()) {
301                        error!(error = %e, "Failed to reset log filtering directives.");
302                    }
303
304                    info!(directives = %restore_directives, "Restored base log filtering directives.");
305                }
306
307                override_timeout.as_mut().reset(tokio::time::Instant::now() + Duration::from_secs(3600));
308            }
309        }
310    }
311
312    // Reset our filter to the base one before we exit.
313    if let Err(e) = state.reload_handle.reload(base_filter) {
314        error!(error = %e, "Failed to reset log filtering directives before override handler shutdown.");
315    }
316}
317
318type LogLevelStream = Pin<Box<dyn Stream<Item = LogLevel> + Send>>;
319
320/// A worker that applies log level changes as they arrive.
321///
322/// Each [`LogLevel`] from the stream replaces the base filter through a [`LoggingOverrideController`], so an active
323/// override stays in place until it expires or is reset. The stream carries levels that are already translated: the
324/// application turns its own configuration changes into `LogLevel`s, and decides what to do with values that it can't
325/// translate.
326///
327/// The worker keeps the stream across restarts, so a restarted worker picks up where the previous run left off. If the
328/// stream ends, the worker waits for shutdown rather than returning, so that it doesn't drive the supervisor into an
329/// infinite restart loop.
330pub struct DynamicLogLevelWorker {
331    levels: Arc<Mutex<LogLevelStream>>,
332    controller: LoggingOverrideController,
333}
334
335impl DynamicLogLevelWorker {
336    /// Creates a new `DynamicLogLevelWorker` that applies each log level from `levels` through `controller`.
337    pub fn new<S>(levels: S, controller: LoggingOverrideController) -> Self
338    where
339        S: Stream<Item = LogLevel> + Send + 'static,
340    {
341        Self {
342            levels: Arc::new(Mutex::new(Box::pin(levels))),
343            controller,
344        }
345    }
346}
347
348#[async_trait]
349impl Supervisable for DynamicLogLevelWorker {
350    fn name(&self) -> &str {
351        "dynamic-log-level"
352    }
353
354    async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
355        let mut levels = self.levels.clone().lock_owned().await;
356        let controller = self.controller.clone();
357
358        Ok(Box::pin(async move {
359            pin!(process_shutdown);
360
361            debug!("Dynamic log level worker started.");
362
363            loop {
364                select! {
365                    _ = &mut process_shutdown => break,
366                    maybe_level = levels.next() => match maybe_level {
367                        Some(log_level) => {
368                            if let Err(e) = controller.update_base(log_level.as_targets()).await {
369                                warn!(error = %e, %log_level, "Failed to apply updated log level.");
370                            }
371                        }
372                        None => {
373                            debug!("Log level stream ended. No further log level changes will be applied.");
374                            (&mut process_shutdown).await;
375                            break;
376                        }
377                    },
378                }
379            }
380
381            debug!("Dynamic log level worker stopped.");
382
383            Ok(())
384        }))
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use std::time::Duration;
391
392    use tokio::{sync::mpsc, time::sleep};
393    use tracing_subscriber::{reload, Registry};
394
395    use super::*;
396
397    fn filter(directives: &str) -> Targets {
398        parse_filter_directives(directives).expect("valid filter directives")
399    }
400
401    fn current_filter(handle: &reload::Handle<Targets, Registry>) -> String {
402        handle
403            .clone_current()
404            .expect("reload layer should be alive")
405            .to_string()
406    }
407
408    async fn wait_for_filter(handle: &reload::Handle<Targets, Registry>, expected: &str) {
409        let deadline = tokio::time::Instant::now() + Duration::from_secs(1);
410
411        loop {
412            let actual = current_filter(handle);
413            if actual == expected {
414                return;
415            }
416
417            assert!(
418                tokio::time::Instant::now() < deadline,
419                "filter did not become `{expected}`; last value was `{actual}`"
420            );
421
422            sleep(Duration::from_millis(10)).await;
423        }
424    }
425
426    fn spawn_processor(
427        base_filter: Targets,
428    ) -> (
429        LoggingOverrideController,
430        reload::Handle<Targets, Registry>,
431        tokio::task::JoinHandle<()>,
432        reload::Layer<Targets, Registry>,
433    ) {
434        let (filter_layer, reload_handle) = reload::Layer::new(base_filter);
435        let (tx, rx) = mpsc::channel(1);
436        let controller = LoggingOverrideController { tx };
437        let mut state = LoggingOverrideWorkerState {
438            reload_handle: reload_handle.clone(),
439            rx,
440        };
441        let processor = tokio::spawn(async move {
442            process_override_actions(&mut state, ShutdownHandle::noop()).await;
443        });
444
445        // The reload layer must outlive the handles -- callers should bind it (e.g., `let _layer = ...`) to keep
446        // the handle alive for the duration of the test.
447        (controller, reload_handle, processor, filter_layer)
448    }
449
450    fn log_level(directives: &str) -> LogLevel {
451        LogLevel::try_from(directives.to_string()).expect("valid log level")
452    }
453
454    /// Returns a stream that yields each log level sent on the returned sender.
455    fn log_level_stream() -> (mpsc::Sender<LogLevel>, impl Stream<Item = LogLevel> + Send + 'static) {
456        let (tx, rx) = mpsc::channel(4);
457        let levels = futures::stream::unfold(rx, |mut rx| async move { rx.recv().await.map(|level| (level, rx)) });
458        (tx, levels)
459    }
460
461    async fn next_base_update(actions: &mut mpsc::Receiver<LoggingOverrideAction>) -> String {
462        let action = tokio::time::timeout(Duration::from_secs(2), actions.recv())
463            .await
464            .expect("worker should send an action")
465            .expect("worker should still be running");
466        match action {
467            LoggingOverrideAction::UpdateBase(filter) => filter.to_string(),
468            _ => panic!("worker should only update the base filter"),
469        }
470    }
471
472    #[tokio::test]
473    async fn dynamic_log_level_worker_applies_each_level_as_the_base_filter() {
474        let (tx, mut actions) = mpsc::channel(1);
475        let (levels_tx, levels) = log_level_stream();
476        let worker = DynamicLogLevelWorker::new(levels, LoggingOverrideController { tx });
477
478        let (coordinator, shutdown) = ShutdownHandle::paired();
479        let run = tokio::spawn(worker.initialize(shutdown).await.expect("worker initializes"));
480
481        for directives in ["saluki=debug", "saluki=warn"] {
482            levels_tx.send(log_level(directives)).await.unwrap();
483            assert_eq!(next_base_update(&mut actions).await, directives);
484        }
485
486        coordinator.shutdown();
487        run.await.expect("worker task completes").expect("worker exits cleanly");
488    }
489
490    #[tokio::test]
491    async fn dynamic_log_level_worker_waits_for_shutdown_after_the_stream_ends() {
492        let (tx, _actions) = mpsc::channel(1);
493        let worker = DynamicLogLevelWorker::new(futures::stream::empty(), LoggingOverrideController { tx });
494
495        let (coordinator, shutdown) = ShutdownHandle::paired();
496        let mut run = tokio::spawn(worker.initialize(shutdown).await.expect("worker initializes"));
497
498        // Returning early would get the worker restarted, so it has to keep running until shutdown. Nothing else can
499        // wake it, so this bound is deterministic rather than timing-dependent.
500        assert!(tokio::time::timeout(Duration::from_millis(100), &mut run)
501            .await
502            .is_err());
503
504        coordinator.shutdown();
505        run.await.expect("worker task completes").expect("worker exits cleanly");
506    }
507
508    #[tokio::test]
509    async fn dynamic_log_level_worker_keeps_the_stream_across_restarts() {
510        let (tx, mut actions) = mpsc::channel(1);
511        let (levels_tx, levels) = log_level_stream();
512        let worker = DynamicLogLevelWorker::new(levels, LoggingOverrideController { tx });
513
514        // Each pass is a separate run of the same worker, as the supervisor would start after a restart.
515        for directives in ["saluki=debug", "saluki=warn"] {
516            let (coordinator, shutdown) = ShutdownHandle::paired();
517            let run = tokio::spawn(worker.initialize(shutdown).await.expect("worker initializes"));
518
519            levels_tx.send(log_level(directives)).await.unwrap();
520            assert_eq!(next_base_update(&mut actions).await, directives);
521
522            coordinator.shutdown();
523            run.await.expect("worker task completes").expect("worker exits cleanly");
524        }
525    }
526
527    #[tokio::test]
528    async fn reset_restores_current_base_filter() {
529        let (controller, reload_handle, processor, _filter_layer) = spawn_processor(filter("agent_data_plane=info"));
530
531        controller
532            .override_for(Duration::from_secs(60), filter("hyper=warn"))
533            .await
534            .expect("send override");
535        wait_for_filter(&reload_handle, "hyper=warn").await;
536
537        controller
538            .update_base(filter("agent_data_plane=debug"))
539            .await
540            .expect("send update_base");
541        controller.reset().await.expect("send reset");
542        wait_for_filter(&reload_handle, "agent_data_plane=debug").await;
543
544        drop(controller);
545        processor.await.expect("override processor should exit cleanly");
546    }
547
548    #[tokio::test]
549    async fn override_expiration_restores_current_base_filter() {
550        let (controller, reload_handle, processor, _filter_layer) = spawn_processor(filter("agent_data_plane=info"));
551
552        controller
553            .override_for(Duration::from_millis(100), filter("hyper=warn"))
554            .await
555            .expect("send override");
556        wait_for_filter(&reload_handle, "hyper=warn").await;
557
558        controller
559            .update_base(filter("agent_data_plane=warn"))
560            .await
561            .expect("send update_base");
562        wait_for_filter(&reload_handle, "agent_data_plane=warn").await;
563
564        drop(controller);
565        processor.await.expect("override processor should exit cleanly");
566    }
567
568    #[tokio::test]
569    async fn update_base_applies_immediately_when_no_override_active() {
570        let (controller, reload_handle, processor, _filter_layer) = spawn_processor(filter("agent_data_plane=info"));
571
572        controller
573            .update_base(filter("agent_data_plane=debug"))
574            .await
575            .expect("send update_base");
576        wait_for_filter(&reload_handle, "agent_data_plane=debug").await;
577
578        drop(controller);
579        processor.await.expect("override processor should exit cleanly");
580    }
581
582    #[tokio::test]
583    async fn override_handler_rejects_span_and_field_filters() {
584        let (tx, mut rx) = mpsc::channel(1);
585        let state = LoggingHandlerState {
586            controller: LoggingOverrideController { tx },
587        };
588
589        let response = LoggingAPIHandler::override_handler(
590            State(state),
591            Query(OverrideQueryParams { time_secs: 60 }),
592            "saluki[span]=debug".to_string(),
593        )
594        .await
595        .into_response();
596
597        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
598        assert!(rx.try_recv().is_err(), "a rejected override must not reach the worker");
599    }
600}