Skip to content

Commit 1130918

Browse files
authored
feat: adding a log file support (#200)
* feat: adding a log file support when configured * chore: clippy fixes * chore: removing unused capability * chore: adding comments * chore: adjusting error messages * chore: adding rotation config opt and moving logging functionality its own module * chore: moving logging to src and combining with logging setup * chore: adding comment around worker guard * chore: adding internal file comment for logging.rs * chore: setting visibility to pub crate' * chore: moving non-config logging code to dedicated runtime/logging submodules * chore: re-adjusting logging module and making rotation case insensitive * chore: fixing logging so error message isn't printed when path is omitted from config * chore: reverting import move * chore: fixing imports' * chore: more import fixes * chore: fixing visibility * chore: fixing config.rs after rebase
1 parent dc49488 commit 1130918

File tree

10 files changed

+215
-87
lines changed

10 files changed

+215
-87
lines changed

.idea/runConfigurations/Run_spacedevs.xml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.lock

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

crates/apollo-mcp-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ serde_json.workspace = true
3838
thiserror.workspace = true
3939
tokio.workspace = true
4040
tracing.workspace = true
41+
tracing-appender = "0.2.3"
4142
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
4243
tokio-util = "0.7.15"
4344
tower-http = { version = "0.6.6", features = ["cors"] }

crates/apollo-mcp-server/src/main.rs

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,12 @@ use apollo_mcp_server::custom_scalar_map::CustomScalarMap;
77
use apollo_mcp_server::errors::ServerError;
88
use apollo_mcp_server::operations::OperationSource;
99
use apollo_mcp_server::server::Server;
10-
use apollo_mcp_server::server::Transport;
1110
use clap::Parser;
1211
use clap::builder::Styles;
1312
use clap::builder::styling::{AnsiColor, Effects};
1413
use runtime::IdOrDefault;
15-
use tracing::{Level, info, warn};
16-
use tracing_subscriber::EnvFilter;
14+
use runtime::logging::Logging;
15+
use tracing::{info, warn};
1716

1817
mod runtime;
1918

@@ -43,33 +42,13 @@ async fn main() -> anyhow::Result<()> {
4342
None => runtime::read_config_from_env().unwrap_or_default(),
4443
};
4544

46-
let mut env_filter = EnvFilter::from_default_env().add_directive(config.logging.level.into());
47-
48-
// Suppress noisy dependency logging at the INFO level
49-
if config.logging.level == Level::INFO {
50-
env_filter = env_filter
51-
.add_directive("rmcp=warn".parse()?)
52-
.add_directive("tantivy=warn".parse()?);
53-
}
54-
55-
// When using the Stdio transport, send output to stderr since stdout is used for MCP messages
56-
match config.transport {
57-
Transport::SSE { .. } | Transport::StreamableHttp { .. } => tracing_subscriber::fmt()
58-
.with_env_filter(env_filter)
59-
.with_ansi(true)
60-
.with_target(false)
61-
.init(),
62-
Transport::Stdio => tracing_subscriber::fmt()
63-
.with_env_filter(env_filter)
64-
.with_writer(std::io::stderr)
65-
.with_ansi(true)
66-
.with_target(false)
67-
.init(),
68-
};
45+
// WorkerGuard is not used but needed to be at least defined or else the guard
46+
// is cleaned up too early and file appender logging does not work
47+
let _guard = Logging::setup(&config)?;
6948

7049
info!(
7150
"Apollo MCP Server v{} // (c) Apollo Graph, Inc. // Licensed under MIT",
72-
std::env!("CARGO_PKG_VERSION")
51+
env!("CARGO_PKG_VERSION")
7352
);
7453

7554
let schema_source = match config.schema {

crates/apollo-mcp-server/src/runtime.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! Runtime utilites
1+
//! Runtime utilities
22
//!
33
//! This module is only used by the main binary and provides helper code
44
//! related to runtime configuration.
@@ -7,7 +7,7 @@ mod config;
77
mod endpoint;
88
mod graphos;
99
mod introspection;
10-
mod logging;
10+
pub mod logging;
1111
mod operation_source;
1212
mod overrides;
1313
mod schema_source;
Lines changed: 100 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
1+
//! Logging config and utilities
2+
//!
3+
//! This module is only used by the main binary and provides logging config structures and setup
4+
//! helper functions
5+
6+
mod defaults;
7+
mod log_rotation_kind;
8+
mod parsers;
9+
10+
use log_rotation_kind::LogRotationKind;
111
use schemars::JsonSchema;
212
use serde::Deserialize;
13+
use std::path::PathBuf;
314
use tracing::Level;
15+
use tracing_appender::non_blocking::WorkerGuard;
16+
use tracing_appender::rolling::RollingFileAppender;
17+
use tracing_subscriber::EnvFilter;
18+
use tracing_subscriber::fmt::writer::BoxMakeWriter;
19+
use tracing_subscriber::layer::SubscriberExt;
20+
use tracing_subscriber::util::SubscriberInitExt;
21+
22+
use super::Config;
423

524
/// Logging related options
625
#[derive(Debug, Deserialize, JsonSchema)]
@@ -10,61 +29,103 @@ pub struct Logging {
1029
default = "defaults::log_level",
1130
deserialize_with = "parsers::from_str"
1231
)]
13-
#[schemars(schema_with = "super::schemas::level")]
32+
#[schemars(schema_with = "level")]
1433
pub level: Level,
34+
35+
/// The output path to use for logging
36+
#[serde(default)]
37+
pub path: Option<PathBuf>,
38+
39+
/// Log file rotation period to use when log file path provided
40+
/// [default: Hourly]
41+
#[serde(default = "defaults::default_rotation")]
42+
pub rotation: LogRotationKind,
1543
}
1644

1745
impl Default for Logging {
1846
fn default() -> Self {
1947
Self {
2048
level: defaults::log_level(),
49+
path: None,
50+
rotation: defaults::default_rotation(),
2151
}
2252
}
2353
}
2454

25-
mod defaults {
26-
use tracing::Level;
27-
28-
pub(super) const fn log_level() -> Level {
29-
Level::INFO
30-
}
31-
}
55+
impl Logging {
56+
pub fn setup(config: &Config) -> Result<Option<WorkerGuard>, anyhow::Error> {
57+
let mut env_filter =
58+
EnvFilter::from_default_env().add_directive(config.logging.level.into());
3259

33-
mod parsers {
34-
use std::{fmt::Display, marker::PhantomData, str::FromStr};
35-
36-
use serde::Deserializer;
60+
if config.logging.level == Level::INFO {
61+
env_filter = env_filter
62+
.add_directive("rmcp=warn".parse()?)
63+
.add_directive("tantivy=warn".parse()?);
64+
}
3765

38-
pub(super) fn from_str<'de, D, T>(deserializer: D) -> Result<T, D::Error>
39-
where
40-
D: Deserializer<'de>,
41-
T: FromStr,
42-
<T as FromStr>::Err: Display,
43-
{
44-
struct FromStrVisitor<Inner> {
45-
_phantom: PhantomData<Inner>,
66+
macro_rules! log_error {
67+
() => {
68+
|e| eprintln!("Failed to setup logging: {e:?}")
69+
};
4670
}
47-
impl<Inner> serde::de::Visitor<'_> for FromStrVisitor<Inner>
48-
where
49-
Inner: FromStr,
50-
<Inner as FromStr>::Err: Display,
51-
{
52-
type Value = Inner;
5371

54-
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
55-
formatter.write_str("a string")
56-
}
72+
let (writer, guard, with_ansi) = match config.logging.path.clone() {
73+
Some(path) => std::fs::create_dir_all(&path)
74+
.map(|_| path)
75+
.inspect_err(log_error!())
76+
.ok()
77+
.and_then(|path| {
78+
RollingFileAppender::builder()
79+
.rotation(config.logging.rotation.clone().into())
80+
.filename_prefix("apollo_mcp_server")
81+
.filename_suffix("log")
82+
.build(path)
83+
.inspect_err(log_error!())
84+
.ok()
85+
})
86+
.map(|appender| {
87+
let (non_blocking_appender, guard) = tracing_appender::non_blocking(appender);
88+
(
89+
BoxMakeWriter::new(non_blocking_appender),
90+
Some(guard),
91+
false,
92+
)
93+
})
94+
.unwrap_or_else(|| {
95+
eprintln!("Log file setup failed - falling back to stderr");
96+
(BoxMakeWriter::new(std::io::stderr), None, true)
97+
}),
98+
None => (BoxMakeWriter::new(std::io::stdout), None, true),
99+
};
57100

58-
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
59-
where
60-
E: serde::de::Error,
61-
{
62-
Inner::from_str(v).map_err(|e| serde::de::Error::custom(e.to_string()))
63-
}
64-
}
101+
tracing_subscriber::registry()
102+
.with(env_filter)
103+
.with(
104+
tracing_subscriber::fmt::layer()
105+
.with_writer(writer)
106+
.with_ansi(with_ansi)
107+
.with_target(false),
108+
)
109+
.init();
65110

66-
deserializer.deserialize_str(FromStrVisitor {
67-
_phantom: PhantomData,
68-
})
111+
Ok(guard)
69112
}
70113
}
114+
115+
fn level(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
116+
/// Log level
117+
#[derive(JsonSchema)]
118+
#[schemars(rename_all = "lowercase")]
119+
// This is just an intermediate type to auto create schema information for,
120+
// so it is OK if it is never used
121+
#[allow(dead_code)]
122+
enum Level {
123+
Trace,
124+
Debug,
125+
Info,
126+
Warn,
127+
Error,
128+
}
129+
130+
Level::json_schema(generator)
131+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
use super::LogRotationKind;
2+
use tracing::Level;
3+
4+
pub(super) const fn log_level() -> Level {
5+
Level::INFO
6+
}
7+
8+
pub(super) const fn default_rotation() -> LogRotationKind {
9+
LogRotationKind::Hourly
10+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
use schemars::JsonSchema;
2+
use serde::Deserialize;
3+
use tracing_appender::rolling::Rotation;
4+
5+
#[derive(Debug, Deserialize, JsonSchema, Clone)]
6+
pub enum LogRotationKind {
7+
#[serde(alias = "minutely", alias = "MINUTELY")]
8+
Minutely,
9+
#[serde(alias = "hourly", alias = "HOURLY")]
10+
Hourly,
11+
#[serde(alias = "daily", alias = "DAILY")]
12+
Daily,
13+
#[serde(alias = "never", alias = "NEVER")]
14+
Never,
15+
}
16+
17+
impl From<LogRotationKind> for Rotation {
18+
fn from(value: LogRotationKind) -> Self {
19+
match value {
20+
LogRotationKind::Minutely => Rotation::MINUTELY,
21+
LogRotationKind::Hourly => Rotation::HOURLY,
22+
LogRotationKind::Daily => Rotation::DAILY,
23+
LogRotationKind::Never => Rotation::NEVER,
24+
}
25+
}
26+
}
27+
28+
#[cfg(test)]
29+
mod tests {
30+
use super::LogRotationKind;
31+
use rstest::rstest;
32+
use tracing_appender::rolling::Rotation;
33+
34+
#[rstest]
35+
#[case(LogRotationKind::Minutely, Rotation::MINUTELY)]
36+
#[case(LogRotationKind::Hourly, Rotation::HOURLY)]
37+
#[case(LogRotationKind::Daily, Rotation::DAILY)]
38+
#[case(LogRotationKind::Never, Rotation::NEVER)]
39+
fn it_maps_to_rotation_correctly(
40+
#[case] log_rotation_kind: LogRotationKind,
41+
#[case] expected: Rotation,
42+
) {
43+
let actual: Rotation = log_rotation_kind.into();
44+
assert_eq!(expected, actual);
45+
}
46+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
use std::{fmt::Display, marker::PhantomData, str::FromStr};
2+
3+
use serde::Deserializer;
4+
5+
pub(crate) fn from_str<'de, D, T>(deserializer: D) -> Result<T, D::Error>
6+
where
7+
D: Deserializer<'de>,
8+
T: FromStr,
9+
<T as FromStr>::Err: Display,
10+
{
11+
struct FromStrVisitor<Inner> {
12+
_phantom: PhantomData<Inner>,
13+
}
14+
impl<Inner> serde::de::Visitor<'_> for FromStrVisitor<Inner>
15+
where
16+
Inner: FromStr,
17+
<Inner as FromStr>::Err: Display,
18+
{
19+
type Value = Inner;
20+
21+
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
22+
formatter.write_str("a string")
23+
}
24+
25+
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
26+
where
27+
E: serde::de::Error,
28+
{
29+
Inner::from_str(v).map_err(|e| serde::de::Error::custom(e.to_string()))
30+
}
31+
}
32+
33+
deserializer.deserialize_str(FromStrVisitor {
34+
_phantom: PhantomData,
35+
})
36+
}

crates/apollo-mcp-server/src/runtime/schemas.rs

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,3 @@ pub(super) fn header_map(generator: &mut schemars::SchemaGenerator) -> schemars:
66
// A header map is just a hash map of string to string with extra validation
77
HashMap::<String, String>::json_schema(generator)
88
}
9-
10-
pub(super) fn level(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
11-
/// Log level
12-
#[derive(JsonSchema)]
13-
#[schemars(rename_all = "lowercase")]
14-
// This is just an intermediate type to auto create schema information for,
15-
// so it is OK if it is never used
16-
#[allow(dead_code)]
17-
enum Level {
18-
Trace,
19-
Debug,
20-
Info,
21-
Warn,
22-
Error,
23-
}
24-
25-
Level::json_schema(generator)
26-
}

0 commit comments

Comments
 (0)