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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ dist/

node_modules
mcpr.toml
logs
21 changes: 21 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,18 @@ struct FileTunnelConfig {
subdomain: Option<String>,
}

/// `[logging]` table in config file
#[derive(serde::Deserialize, Default)]
#[serde(default)]
struct FileLoggingConfig {
/// Enable JSONL file logging.
file: bool,
/// Directory for log files (default: "./logs").
dir: Option<String>,
/// Rotation strategy: "daily" or "size:50MB" (default: "daily").
rotation: Option<String>,
}

/// Config file format (mcpr.toml)
#[derive(serde::Deserialize, Default)]
#[serde(default)]
Expand All @@ -145,6 +157,9 @@ struct FileConfig {
// -- Tunnel client --
tunnel: FileTunnelConfig,

// -- Logging --
logging: FileLoggingConfig,

max_request_body_size: Option<usize>,
max_response_body_size: Option<usize>,
max_concurrent_upstream: Option<usize>,
Expand Down Expand Up @@ -239,6 +254,9 @@ pub struct GatewayConfig {
pub max_concurrent_upstream: Option<usize>,
pub connect_timeout: Option<u64>,
pub request_timeout: Option<u64>,
pub log_file: bool,
pub log_dir: Option<String>,
pub log_rotation: Option<String>,
}

impl GatewayConfig {
Expand Down Expand Up @@ -559,6 +577,9 @@ fn load_gateway(cli: Cli, file: FileConfig, config_path: Option<std::path::PathB
max_concurrent_upstream: file.max_concurrent_upstream,
connect_timeout: file.connect_timeout,
request_timeout: file.request_timeout,
log_file: file.logging.file,
log_dir: file.logging.dir,
log_rotation: file.logging.rotation,
})
}

Expand Down
7 changes: 1 addition & 6 deletions src/display.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::tui::state::{LogEntry, SharedTuiState};
use crate::tui::SharedTuiState;

/// Populate the TUI state with startup info.
pub fn log_startup(
Expand All @@ -14,8 +14,3 @@ pub fn log_startup(
s.mcp_upstream = mcp_upstream.to_string();
s.widgets = widgets.unwrap_or("(none)").to_string();
}

/// Push a request log entry to the TUI state.
pub fn log_request(state: &SharedTuiState, entry: LogEntry) {
state.lock().unwrap().push_log(entry);
}
113 changes: 113 additions & 0 deletions src/logger/entry.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
use std::time::Instant;

use serde::Serialize;

/// A single request log entry capturing HTTP + MCP telemetry.
///
/// Constructed via the builder pattern:
/// ```ignore
/// LogEntry::new("POST", "/mcp", 200, "rewritten")
/// .mcp_method("tools/call")
/// .detail("get_weather")
/// .session_id("sid-123")
/// .upstream("http://localhost:9000/mcp")
/// .size(147)
/// .duration(start)
/// .upstream_duration(7)
/// ```
#[derive(Clone, Serialize)]
pub struct LogEntry {
pub timestamp: String,
/// ISO 8601 timestamp for machine-readable output (JSONL).
pub timestamp_utc: String,
pub method: String,
pub path: String,
pub mcp_method: Option<String>,
pub session_id: Option<String>,
pub status: u16,
pub note: String,
pub upstream_url: Option<String>,
pub resp_size: Option<usize>,
pub duration_ms: Option<u64>,
/// Time spent waiting for upstream (network). Proxy overhead = duration_ms - upstream_ms.
pub upstream_ms: Option<u64>,
/// JSON-RPC error code from the response body (if the response is a JSON-RPC error).
pub jsonrpc_error: Option<(i64, String)>,
/// Extra detail: tool name for tools/call, resource URI for resources/read, etc.
pub detail: Option<String>,
}

impl LogEntry {
pub fn new(method: &str, path: &str, status: u16, note: &str) -> Self {
let now = chrono::Utc::now();
Self {
timestamp: now
.with_timezone(&chrono::Local)
.format("%H:%M:%S")
.to_string(),
timestamp_utc: now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
method: method.to_string(),
path: path.to_string(),
mcp_method: None,
session_id: None,
status,
note: note.to_string(),
upstream_url: None,
resp_size: None,
duration_ms: None,
upstream_ms: None,
jsonrpc_error: None,
detail: None,
}
}

pub fn session_id(mut self, id: &str) -> Self {
self.session_id = Some(id.to_string());
self
}

pub fn maybe_session_id(mut self, id: Option<&str>) -> Self {
self.session_id = id.map(String::from);
self
}

pub fn mcp_method(mut self, m: &str) -> Self {
self.mcp_method = Some(m.to_string());
self
}

pub fn upstream(mut self, url: &str) -> Self {
self.upstream_url = Some(url.to_string());
self
}

pub fn size(mut self, bytes: usize) -> Self {
self.resp_size = Some(bytes);
self
}

pub fn duration(mut self, start: Instant) -> Self {
self.duration_ms = Some(start.elapsed().as_millis() as u64);
self
}

pub fn upstream_duration(mut self, ms: u64) -> Self {
self.upstream_ms = Some(ms);
self
}

pub fn jsonrpc_error(mut self, code: i64, message: &str) -> Self {
self.jsonrpc_error = Some((code, message.to_string()));
self
}

pub fn detail(mut self, d: &str) -> Self {
self.detail = Some(d.to_string());
self
}

pub fn maybe_detail(mut self, d: Option<&str>) -> Self {
self.detail = d.map(String::from);
self
}
}
Loading
Loading