Split src/ into an api/cli/engine/config module hierarchy

- src/api/: HTTP router + DTOs + the MCP server (the HTTP/MCP-facing
  surface): mod.rs (formerly api.rs), dto.rs, mcp_server.rs.
- src/cli/: argument parsing and process entry (formerly inline in
  main.rs) plus session registry: mod.rs, sessions.rs. main.rs is now
  a thin entry point that just declares modules and calls cli::run().
- src/engine/: the actor + everything it depends on to parse/index/
  query log lines: mod.rs (formerly engine.rs), alerts.rs, promql.rs,
  rate.rs, ingest.rs, logfmt.rs, filter_parser.rs/.pest,
  filter_datetime.rs, index_key.rs, data_value.rs, metric_primitive.rs,
  timestamp.rs.
- src/config/: persisted configuration: mod.rs (formerly settings.rs),
  profile.rs.
- src/consts.rs stays at the crate root, used from every module above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
This commit is contained in:
Hugh Kaznowski
2026-09-06 17:15:39 +01:00
co-authored by Claude Sonnet 5
parent 311713986a
commit a810ecf396
21 changed files with 302 additions and 296 deletions
+6 -6
View File
@@ -1,8 +1,8 @@
use crate::alerts::AlertCondition; use crate::engine::alerts::AlertCondition;
use crate::data_value::MetricPrimitiveKind; use crate::engine::data_value::MetricPrimitiveKind;
use crate::metric_primitive::MetricPrimitive; use crate::engine::metric_primitive::MetricPrimitive;
use crate::profile::{ChartType, Dashboard, Panel, Profile}; use crate::config::profile::{ChartType, Dashboard, Panel, Profile};
use crate::settings::{ColorScheme, CssEntry, Settings}; use crate::config::{ColorScheme, CssEntry, Settings};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -144,7 +144,7 @@ impl From<&CssEntry> for CssEntryDto {
path_or_url: None, path_or_url: None,
}, },
CssEntry::Linked { path_or_url } => CssEntryDto { CssEntry::Linked { path_or_url } => CssEntryDto {
id: crate::settings::css_entry_id(entry), id: crate::config::css_entry_id(entry),
kind: CssEntryKind::Linked, kind: CssEntryKind::Linked,
filename: None, filename: None,
path_or_url: Some(path_or_url.clone()), path_or_url: Some(path_or_url.clone()),
+12 -9
View File
@@ -1,21 +1,24 @@
pub mod dto;
pub mod mcp_server;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use crate::dto::{ use crate::api::dto::{
BucketValuesParams, CreateAlertRuleRequest, CreateDashboardRequest, CreateProfileRequest, BucketValuesParams, CreateAlertRuleRequest, CreateDashboardRequest, CreateProfileRequest,
CssEntryDto, DashboardDto, ErrorDto, ImportCssRequest, LinkCssRequest, MetricDeclarationDto, CssEntryDto, DashboardDto, ErrorDto, ImportCssRequest, LinkCssRequest, MetricDeclarationDto,
ParamDeclarationDto, LogsParams, ProfileSummaryDto, PromqlParams, SaveDashboardRequest, ParamDeclarationDto, LogsParams, ProfileSummaryDto, PromqlParams, SaveDashboardRequest,
SettingsDto, TestAlertNotificationRequest, TimelineParams, UpdateAlertRuleRequest, UpdateSettingsRequest, SettingsDto, TestAlertNotificationRequest, TimelineParams, UpdateAlertRuleRequest, UpdateSettingsRequest,
}; };
use crate::data_value::MetricPrimitiveKind; use crate::engine::data_value::MetricPrimitiveKind;
use crate::engine::{ use crate::engine::{
DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage, EngineQuery, DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage, EngineQuery,
EngineResponse, EngineResponse,
}; };
use crate::alerts::{deliver_ntfy, AlertError}; use crate::engine::alerts::{deliver_ntfy, AlertError};
use crate::profile::{Dashboard, ParamDeclarationRecord, Profile, ProfileError}; use crate::config::profile::{Dashboard, ParamDeclarationRecord, Profile, ProfileError};
use crate::settings::{CssError, SettingsHandle}; use crate::config::{CssError, SettingsHandle};
use axum::extract::{Path as AxumPath, Query, State}; use axum::extract::{Path as AxumPath, Query, State};
use axum::http::{header, StatusCode}; use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Json, Response}; use axum::response::{IntoResponse, Json, Response};
@@ -26,7 +29,7 @@ use tokio::sync::oneshot;
static WEBAPP_DIST: EmbedDir = fs_embed::fs_embed!("webapp/dist"); static WEBAPP_DIST: EmbedDir = fs_embed::fs_embed!("webapp/dist");
const SKILL_MD: &str = include_str!("../SKILL.md"); const SKILL_MD: &str = include_str!("../../SKILL.md");
#[derive(Clone)] #[derive(Clone)]
struct ApiState { struct ApiState {
@@ -577,7 +580,7 @@ async fn import_css(
let entry = settings let entry = settings
.css_entries .css_entries
.iter() .iter()
.find(|entry| crate::settings::css_entry_id(entry) == id) .find(|entry| crate::config::css_entry_id(entry) == id)
.expect("entry was just inserted by import_css"); .expect("entry was just inserted by import_css");
(StatusCode::CREATED, Json(CssEntryDto::from(entry))).into_response() (StatusCode::CREATED, Json(CssEntryDto::from(entry))).into_response()
} }
@@ -900,8 +903,8 @@ async fn delete_dashboard(
mod tests { mod tests {
use super::*; use super::*;
use crate::engine::{self, IngestedLine}; use crate::engine::{self, IngestedLine};
use crate::settings::Settings; use crate::config::Settings;
use crate::timestamp::Timestamp; use crate::engine::timestamp::Timestamp;
use axum::body::Body; use axum::body::Body;
use axum::http::Request; use axum::http::Request;
use http_body_util::BodyExt; use http_body_util::BodyExt;
+205
View File
@@ -0,0 +1,205 @@
pub mod sessions;
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, Mutex};
use clap::{Parser, Subcommand};
use tokio::net::TcpListener;
use crate::config::Settings;
static SETTINGS_PATH_HELP: LazyLock<String> = LazyLock::new(|| {
format!(
"Path to the settings file. Defaults to {}.",
crate::config::default_settings_path()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "a per-OS config location".to_string())
)
});
#[derive(Parser, Debug)]
struct Args {
#[command(subcommand)]
command: Option<Command>,
#[arg(long, default_value_t = 0)]
port: u16,
#[arg(long, help = SETTINGS_PATH_HELP.as_str())]
settings_path: Option<PathBuf>,
#[arg(long)]
file: Option<PathBuf>,
#[arg(long)]
metrics_url: Option<String>,
#[arg(long)]
name: Option<String>,
}
#[derive(Subcommand, Debug)]
enum Command {
Mcp {
#[arg(long)]
session: Option<String>,
#[arg(long)]
port: Option<u16>,
},
Sessions,
}
pub async fn run() {
let args = Args::parse();
let settings_path = args.settings_path.clone().unwrap_or_else(|| {
crate::config::default_settings_path()
.expect("could not determine a default settings path; pass --settings-path")
});
match args.command {
Some(Command::Mcp { session, port }) => {
run_mcp(&settings_path, session, port).await;
return;
}
Some(Command::Sessions) => {
print_sessions(&settings_path);
return;
}
None => {}
}
let settings = Arc::new(Mutex::new(Settings::load(&settings_path)));
let engine_tx = crate::engine::spawn(settings.clone(), crate::consts::ENGINE_CHANNEL_CAPACITY);
{
let max_logs = settings.lock().unwrap().max_logs;
let _ = engine_tx.tx.send(crate::engine::EngineMessage::SetMaxLogs(max_logs)).await;
}
{
let (active_profile_name, active_profile) = {
let mut settings_guard = settings.lock().unwrap();
settings_guard
.ensure_default_profile(&settings_path)
.expect("failed to ensure the Default profile exists");
let active_profile_name = settings_guard.active_profile.clone();
let active_profile = active_profile_name
.as_deref()
.and_then(|name| settings_guard.profile(name).cloned());
(active_profile_name, active_profile)
};
match (active_profile_name, active_profile) {
(Some(_), Some(active_profile)) => {
active_profile.load_into_engine(&engine_tx).await;
}
(Some(profile_name), None) => {
eprintln!(
"warning: active profile '{profile_name}' not found in {}; skipping",
settings_path.display()
);
}
(None, _) => {}
}
}
match args.file.clone() {
Some(path) => {
tokio::spawn(crate::engine::ingest::run_file(engine_tx.clone(), path));
}
None => {
tokio::spawn(crate::engine::ingest::run(engine_tx.clone()));
}
}
if let Some(metrics_url) = args.metrics_url.clone() {
tokio::spawn(crate::engine::ingest::run_metrics_poll(engine_tx.clone(), metrics_url));
}
let app = crate::api::router(engine_tx, settings, settings_path.clone());
let listener = TcpListener::bind((crate::consts::DEFAULT_BIND_HOST, args.port))
.await
.expect("failed to bind listener");
let bound_port = listener
.local_addr()
.expect("failed to read back bound address")
.port();
let source = match &args.file {
Some(path) => format!("tailing {}", path.display()),
None => "reading logs from stdin".to_string(),
};
let session_name = args.name.clone().unwrap_or_else(sessions::generate_name);
match sessions::register_session(&settings_path, &session_name, bound_port) {
Ok(_) => eprintln!(
"ottyl serving on http://localhost:{} ({}), session name: {}",
bound_port, source, session_name
),
Err(err) => eprintln!(
"ottyl serving on http://localhost:{} ({}); warning: failed to register session '{}': {}",
bound_port, source, session_name, err
),
}
axum::serve(listener, app)
.await
.expect("server error");
}
async fn run_mcp(settings_path: &std::path::Path, session: Option<String>, port: Option<u16>) {
use rmcp::ServiceExt;
let port = match (port, session) {
(Some(port), _) => port,
(None, Some(name)) => match sessions::resolve_session(settings_path, &name) {
Some(info) => info.port,
None => {
eprintln!("error: no running ottyl session named '{name}'");
std::process::exit(1);
}
},
(None, None) => {
eprintln!("error: `ottyl mcp` requires --session <name> or --port <port>");
std::process::exit(1);
}
};
let base_url = format!("http://localhost:{port}");
let service = crate::api::mcp_server::OttylMcp::new(base_url)
.serve(rmcp::transport::stdio())
.await
.expect("failed to start MCP server");
service.waiting().await.expect("MCP server error");
}
fn print_sessions(settings_path: &std::path::Path) {
let dir = sessions::sessions_dir(settings_path);
let sessions = sessions::list_sessions(&dir);
if sessions.is_empty() {
println!("no known ottyl sessions");
return;
}
for session in sessions {
println!("{}\tport={}\tpid={}", session.name, session.port, session.pid);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_path_defaults_to_none() {
let args = Args::parse_from(["ottyl"]);
assert_eq!(args.settings_path, None);
}
#[test]
fn settings_path_flag_overrides_default() {
let args = Args::parse_from(["ottyl", "--settings-path", "/tmp/custom-settings.json"]);
assert_eq!(
args.settings_path,
Some(PathBuf::from("/tmp/custom-settings.json"))
);
}
}
+9 -7
View File
@@ -1,3 +1,5 @@
pub mod profile;
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use std::fmt; use std::fmt;
use std::fs; use std::fs;
@@ -10,8 +12,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
use directories::ProjectDirs; use directories::ProjectDirs;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::alerts::AlertRule; use crate::engine::alerts::AlertRule;
use crate::profile::Profile; use crate::config::profile::Profile;
pub type SettingsHandle = Arc<Mutex<Settings>>; pub type SettingsHandle = Arc<Mutex<Settings>>;
@@ -197,7 +199,7 @@ impl Settings {
.map(|parent| parent.join("profiles")) .map(|parent| parent.join("profiles"))
.unwrap_or_else(|| PathBuf::from("profiles")) .unwrap_or_else(|| PathBuf::from("profiles"))
.join("profiles.json"); .join("profiles.json");
let Some(profiles) = crate::profile::Profile::load_legacy_store(&legacy_path) else { let Some(profiles) = crate::config::profile::Profile::load_legacy_store(&legacy_path) else {
return; return;
}; };
self.profiles = profiles; self.profiles = profiles;
@@ -218,7 +220,7 @@ impl Settings {
} }
fn migrate_legacy_profile_shapes(&mut self, settings_path: &Path) { fn migrate_legacy_profile_shapes(&mut self, settings_path: &Path) {
if crate::profile::migrate_legacy_profile_shapes(&mut self.profiles) if crate::config::profile::migrate_legacy_profile_shapes(&mut self.profiles)
&& let Err(err) = self.save(settings_path) && let Err(err) = self.save(settings_path)
{ {
eprintln!( eprintln!(
@@ -418,13 +420,13 @@ mod tests {
settings.css_entries.push(CssEntry::Linked { settings.css_entries.push(CssEntry::Linked {
path_or_url: "https://example.com/style.css".to_string(), path_or_url: "https://example.com/style.css".to_string(),
}); });
settings.profiles.push(crate::profile::Profile::new("default".to_string())); settings.profiles.push(crate::config::profile::Profile::new("default".to_string()));
settings.active_profile = Some("default".to_string()); settings.active_profile = Some("default".to_string());
settings.alert_rules.push(crate::alerts::AlertRule { settings.alert_rules.push(crate::engine::alerts::AlertRule {
id: "rule1".to_string(), id: "rule1".to_string(),
name: "error spike".to_string(), name: "error spike".to_string(),
enabled: true, enabled: true,
condition: crate::alerts::AlertCondition::Immediate { condition: crate::engine::alerts::AlertCondition::Immediate {
query: r#"regex("error")"#.to_string(), query: r#"regex("error")"#.to_string(),
}, },
}); });
+6 -6
View File
@@ -12,9 +12,9 @@ use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::consts::{DEFAULT_PROFILE_NAME, SEEDED_METRIC_LABEL, SEEDED_METRIC_NAME}; use crate::consts::{DEFAULT_PROFILE_NAME, SEEDED_METRIC_LABEL, SEEDED_METRIC_NAME};
use crate::data_value::MetricPrimitiveKind; use crate::engine::data_value::MetricPrimitiveKind;
use crate::engine::{DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage}; use crate::engine::{DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage};
use crate::settings::Settings; use crate::config::Settings;
use tokio::sync::oneshot; use tokio::sync::oneshot;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -678,10 +678,10 @@ impl Settings {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::data_value::MetricPrimitiveKind; use crate::engine::data_value::MetricPrimitiveKind;
use crate::dto::LogsParams; use crate::api::dto::LogsParams;
use crate::engine::{EngineMessage, EngineQuery, EngineResponse, IngestedLine}; use crate::engine::{EngineMessage, EngineQuery, EngineResponse, IngestedLine};
use crate::timestamp::Timestamp; use crate::engine::timestamp::Timestamp;
fn param_request(name: &str, pattern: &str, capture_group: &str) -> DeclareParamRequest { fn param_request(name: &str, pattern: &str, capture_group: &str) -> DeclareParamRequest {
DeclareParamRequest { DeclareParamRequest {
@@ -798,7 +798,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn seeded_metric_renders_real_error_vs_other_data() { async fn seeded_metric_renders_real_error_vs_other_data() {
use crate::engine::{self, EngineMessage, EngineQuery, EngineResponse, IngestedLine}; use crate::engine::{self, EngineMessage, EngineQuery, EngineResponse, IngestedLine};
use crate::timestamp::Timestamp; use crate::engine::timestamp::Timestamp;
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json"); let settings_path = dir.path().join("settings.json");
+6 -6
View File
@@ -10,11 +10,11 @@ use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::engine::{self, eval_json_comparison}; use crate::engine::{self, eval_json_comparison};
use crate::filter_datetime; use crate::engine::filter_datetime;
use crate::filter_parser::{self, CompareOp, FilterExpr, FilterValue}; use crate::engine::filter_parser::{self, CompareOp, FilterExpr, FilterValue};
use crate::rate::RateWindow; use crate::engine::rate::RateWindow;
use crate::settings::Settings; use crate::config::Settings;
use crate::timestamp::Timestamp; use crate::engine::timestamp::Timestamp;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AlertRule { pub struct AlertRule {
@@ -190,7 +190,7 @@ fn eval_alert_expr(expr: &FilterExpr, sequence: u64, timestamp: Timestamp, text:
FilterValue::Number(n) => n.to_string(), FilterValue::Number(n) => n.to_string(),
FilterValue::Raw(raw) => raw.clone(), FilterValue::Raw(raw) => raw.clone(),
}; };
Ok(op.matches(crate::logfmt::detect_format(text).cmp(target.as_str()))) Ok(op.matches(crate::engine::logfmt::detect_format(text).cmp(target.as_str())))
} }
FilterExpr::Comparison { metric, .. } => Err(format!( FilterExpr::Comparison { metric, .. } => Err(format!(
"alert queries only support regex()/ottyl_seq/ottyl_ts/ottyl_fmt (not metric comparisons like \ "alert queries only support regex()/ottyl_seq/ottyl_ts/ottyl_fmt (not metric comparisons like \
@@ -1,5 +1,5 @@
use crate::metric_primitive::MetricPrimitive; use crate::engine::metric_primitive::MetricPrimitive;
use crate::timestamp::Timestamp; use crate::engine::timestamp::Timestamp;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -2,7 +2,7 @@
use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc}; use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
use crate::timestamp::Timestamp; use crate::engine::timestamp::Timestamp;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum DateTimeParseError { pub enum DateTimeParseError {
@@ -6,7 +6,7 @@ use pest_derive::Parser as PestParser;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(PestParser)] #[derive(PestParser)]
#[grammar = "filter_parser.pest"] #[grammar = "engine/filter_parser.pest"]
struct FilterGrammar; struct FilterGrammar;
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
+2 -2
View File
@@ -1,5 +1,5 @@
use crate::metric_primitive::MetricPrimitive; use crate::engine::metric_primitive::MetricPrimitive;
use crate::timestamp::Timestamp; use crate::engine::timestamp::Timestamp;
use serde::Serialize; use serde::Serialize;
use std::collections::BTreeMap; use std::collections::BTreeMap;
+1 -1
View File
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use crate::consts::{METRICS_POLL_INTERVAL, STDIN_POLL_INTERVAL as POLL_INTERVAL}; use crate::consts::{METRICS_POLL_INTERVAL, STDIN_POLL_INTERVAL as POLL_INTERVAL};
use crate::engine::{EngineHandle, EngineMessage, IngestedLine, MetricSample}; use crate::engine::{EngineHandle, EngineMessage, IngestedLine, MetricSample};
use crate::timestamp::Timestamp; use crate::engine::timestamp::Timestamp;
use tokio::fs::File; use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::io::{AsyncBufReadExt, BufReader};
+3 -3
View File
@@ -348,7 +348,7 @@ mod tests {
); );
} }
const LOG_STREAM_FIXTURE: &str = include_str!("fixtures/log_stream_sample.txt"); const LOG_STREAM_FIXTURE: &str = include_str!("../fixtures/log_stream_sample.txt");
#[test] #[test]
fn detects_log_stream_shaped_lines() { fn detects_log_stream_shaped_lines() {
@@ -467,8 +467,8 @@ mod tests {
assert!(!extract_pairs(line).is_empty()); assert!(!extract_pairs(line).is_empty());
} }
const GO_LOG_FIXTURE: &str = include_str!("fixtures/go_log_sample.txt"); const GO_LOG_FIXTURE: &str = include_str!("../fixtures/go_log_sample.txt");
const PY_LOG_FIXTURE: &str = include_str!("fixtures/py_log_sample.txt"); const PY_LOG_FIXTURE: &str = include_str!("../fixtures/py_log_sample.txt");
#[test] #[test]
fn detects_go_log_shaped_lines() { fn detects_go_log_shaped_lines() {
@@ -1,4 +1,4 @@
use crate::timestamp::Timestamp; use crate::engine::timestamp::Timestamp;
use serde::Serialize; use serde::Serialize;
use std::cmp::Ordering; use std::cmp::Ordering;
use std::fmt; use std::fmt;
+43 -33
View File
@@ -1,22 +1,32 @@
use crate::alerts::{self, AlertRuntimeState}; pub mod alerts;
pub mod data_value;
pub mod filter_datetime;
pub mod filter_parser;
pub mod index_key;
pub mod ingest;
pub mod logfmt;
pub mod metric_primitive;
pub mod promql;
pub mod rate;
pub mod timestamp;
use crate::engine::alerts::AlertRuntimeState;
use crate::consts::{ use crate::consts::{
DEFAULT_TIMELINE_BUCKET_MS, INGEST_LAG_WATERMARK_MAX_ENTRIES, INGEST_LAG_WATERMARK_WINDOW_MS, DEFAULT_TIMELINE_BUCKET_MS, INGEST_LAG_WATERMARK_MAX_ENTRIES, INGEST_LAG_WATERMARK_WINDOW_MS,
MAX_AUTO_DECLARED_PARAMS, MAX_NEW_AUTO_PARAMS_PER_LINE, MERGE_WINDOW_MS, MAX_AUTO_DECLARED_PARAMS, MAX_NEW_AUTO_PARAMS_PER_LINE, MERGE_WINDOW_MS,
OTTYL_RESERVED_PARAM_NAMES, SORT_HELPER_INDEX_PURGE_AFTER, OTTYL_RESERVED_PARAM_NAMES, SORT_HELPER_INDEX_PURGE_AFTER,
}; };
use crate::data_value::{DataValue, MetricPrimitiveKind, ParamDeclaration}; use crate::engine::data_value::{DataValue, MetricPrimitiveKind, ParamDeclaration};
use crate::dto::{ use crate::api::dto::{
BucketValueDto, LogEntryDto, LogsParams, MetricDeclarationDto, ParamDeclarationDto, BucketValueDto, LogEntryDto, LogsParams, MetricDeclarationDto, ParamDeclarationDto,
PromqlSeriesDto, StatsDto, TimelineBucketDto, TimelineParams, PromqlSeriesDto, StatsDto, TimelineBucketDto, TimelineParams,
}; };
use crate::filter_datetime; use crate::engine::filter_parser::{CompareOp, FilterExpr, FilterValue};
use crate::filter_parser::{self, CompareOp, FilterExpr, FilterValue}; use crate::engine::index_key::IndexKey;
use crate::index_key::IndexKey; use crate::engine::metric_primitive::{MetricPrimitive, Number};
use crate::metric_primitive::{MetricPrimitive, Number}; use crate::engine::promql::{AggOp, LabelMatcher, PromqlQuery};
use crate::promql::{AggOp, LabelMatcher, PromqlQuery}; use crate::config::SettingsHandle;
use crate::rate; use crate::engine::timestamp::Timestamp;
use crate::settings::SettingsHandle;
use crate::timestamp::Timestamp;
use left_right::Absorb; use left_right::Absorb;
use regex::Regex; use regex::Regex;
use serde::Deserialize; use serde::Deserialize;
@@ -227,7 +237,7 @@ async fn run(mut rx: mpsc::Receiver<EngineMessage>, settings: SettingsHandle, mu
let mut max_logs: Option<usize> = None; let mut max_logs: Option<usize> = None;
let mut metric_sample_sequence: u64 = 0; let mut metric_sample_sequence: u64 = 0;
let mut alert_states: HashMap<String, AlertRuntimeState> = HashMap::new(); let mut alert_states: HashMap<String, AlertRuntimeState> = HashMap::new();
let mut log_stream_header: Option<crate::logfmt::LogStreamHeader> = None; let mut log_stream_header: Option<crate::engine::logfmt::LogStreamHeader> = None;
let mut ingest_lag_samples: Vec<(Timestamp, i64)> = Vec::new(); let mut ingest_lag_samples: Vec<(Timestamp, i64)> = Vec::new();
while let Some(message) = rx.recv().await { while let Some(message) = rx.recv().await {
@@ -243,7 +253,7 @@ async fn run(mut rx: mpsc::Receiver<EngineMessage>, settings: SettingsHandle, mu
let ingest_lag_high_watermark_ms = let ingest_lag_high_watermark_ms =
ingest_lag_high_watermark_ms(&ingest_lag_samples, processed_at); ingest_lag_high_watermark_ms(&ingest_lag_samples, processed_at);
let is_new_header = crate::logfmt::LogStreamHeader::detect(&text).is_some(); let is_new_header = crate::engine::logfmt::LogStreamHeader::detect(&text).is_some();
log_stream_header = log_stream_header_transition(&log_stream_header, &text); log_stream_header = log_stream_header_transition(&log_stream_header, &text);
let row_header = if is_new_header { None } else { log_stream_header.clone() }; let row_header = if is_new_header { None } else { log_stream_header.clone() };
@@ -493,8 +503,8 @@ impl EngineSnapshot {
if total >= MAX_AUTO_DECLARED_PARAMS { if total >= MAX_AUTO_DECLARED_PARAMS {
return; return;
} }
let candidates: Vec<AutoParamCandidate> = if crate::logfmt::detect_format(text) == "json" { let candidates: Vec<AutoParamCandidate> = if crate::engine::logfmt::detect_format(text) == "json" {
crate::data_value::flatten_json_object(text) crate::engine::data_value::flatten_json_object(text)
.into_iter() .into_iter()
.map(|(path, value)| AutoParamCandidate { .map(|(path, value)| AutoParamCandidate {
name: path.clone(), name: path.clone(),
@@ -505,7 +515,7 @@ impl EngineSnapshot {
}) })
.collect() .collect()
} else { } else {
crate::logfmt::extract_pairs(text) crate::engine::logfmt::extract_pairs(text)
.into_iter() .into_iter()
.map(|(key, value, quoted)| { .map(|(key, value, quoted)| {
let group = sanitize_group_name(key); let group = sanitize_group_name(key);
@@ -549,18 +559,18 @@ impl EngineSnapshot {
} }
fn log_stream_header_transition( fn log_stream_header_transition(
current: &Option<crate::logfmt::LogStreamHeader>, current: &Option<crate::engine::logfmt::LogStreamHeader>,
text: &str, text: &str,
) -> Option<crate::logfmt::LogStreamHeader> { ) -> Option<crate::engine::logfmt::LogStreamHeader> {
crate::logfmt::LogStreamHeader::detect(text).or_else(|| current.clone()) crate::engine::logfmt::LogStreamHeader::detect(text).or_else(|| current.clone())
} }
fn derive_log_stream_params_from_row( fn derive_log_stream_params_from_row(
index: &mut BTreeMap<IndexKey, DataValue>, index: &mut BTreeMap<IndexKey, DataValue>,
header: &crate::logfmt::LogStreamHeader, header: &crate::engine::logfmt::LogStreamHeader,
text: &str, text: &str,
) { ) {
if !crate::logfmt::is_log_stream_line(text) { if !crate::engine::logfmt::is_log_stream_line(text) {
return; return;
} }
let row = header.split_row(text); let row = header.split_row(text);
@@ -605,10 +615,10 @@ fn derive_log_stream_params_from_row(
#[cfg(test)] #[cfg(test)]
fn auto_declare_log_stream_params( fn auto_declare_log_stream_params(
index: &mut BTreeMap<IndexKey, DataValue>, index: &mut BTreeMap<IndexKey, DataValue>,
header: &mut Option<crate::logfmt::LogStreamHeader>, header: &mut Option<crate::engine::logfmt::LogStreamHeader>,
text: &str, text: &str,
) { ) {
let is_new_header = crate::logfmt::LogStreamHeader::detect(text).is_some(); let is_new_header = crate::engine::logfmt::LogStreamHeader::detect(text).is_some();
*header = log_stream_header_transition(header, text); *header = log_stream_header_transition(header, text);
if is_new_header { if is_new_header {
return; return;
@@ -764,7 +774,7 @@ fn parse_metric_primitive(raw: &str, kind: MetricPrimitiveKind) -> Option<Metric
.ok() .ok()
.map(MetricPrimitive::Number), .map(MetricPrimitive::Number),
MetricPrimitiveKind::Timestamp => { MetricPrimitiveKind::Timestamp => {
crate::filter_datetime::parse_datetime(raw, Timestamp::now()) crate::engine::filter_datetime::parse_datetime(raw, Timestamp::now())
.ok() .ok()
.map(MetricPrimitive::Timestamp) .map(MetricPrimitive::Timestamp)
} }
@@ -811,11 +821,11 @@ fn extract_param_raw(
json_field_path: Option<&str>, json_field_path: Option<&str>,
log_stream_gated: bool, log_stream_gated: bool,
) -> Option<String> { ) -> Option<String> {
if log_stream_gated && !crate::logfmt::is_log_stream_line(text) { if log_stream_gated && !crate::engine::logfmt::is_log_stream_line(text) {
return None; return None;
} }
match json_field_path.filter(|path| !path.is_empty()) { match json_field_path.filter(|path| !path.is_empty()) {
Some(field_path) => crate::data_value::extract_json_field(text, field_path), Some(field_path) => crate::engine::data_value::extract_json_field(text, field_path),
None => regex.captures(text)?.name(capture_group).map(|m| m.as_str().to_string()), None => regex.captures(text)?.name(capture_group).map(|m| m.as_str().to_string()),
} }
} }
@@ -1250,7 +1260,7 @@ impl CompiledFilter {
op.matches(timestamp.as_millis().cmp(target_ms)) op.matches(timestamp.as_millis().cmp(target_ms))
} }
CompiledFilter::Fmt { op, target } => { CompiledFilter::Fmt { op, target } => {
op.matches(crate::logfmt::detect_format(text).cmp(target.as_str())) op.matches(crate::engine::logfmt::detect_format(text).cmp(target.as_str()))
} }
CompiledFilter::MetricMatch(sequences) => sequences.contains(&sequence), CompiledFilter::MetricMatch(sequences) => sequences.contains(&sequence),
CompiledFilter::Json { field, op, value } => eval_json_comparison(text, field, *op, value), CompiledFilter::Json { field, op, value } => eval_json_comparison(text, field, *op, value),
@@ -1356,7 +1366,7 @@ fn derive_single_param(
) -> Option<MetricPrimitive> { ) -> Option<MetricPrimitive> {
let (_, regex, capture_group, json_field_path, kind, log_stream_gated) = let (_, regex, capture_group, json_field_path, kind, log_stream_gated) =
declarations.iter().find(|(name, ..)| name == param_name)?; declarations.iter().find(|(name, ..)| name == param_name)?;
if *log_stream_gated && !crate::logfmt::is_log_stream_line(text) { if *log_stream_gated && !crate::engine::logfmt::is_log_stream_line(text) {
return None; return None;
} }
if *kind == MetricPrimitiveKind::Boolean { if *kind == MetricPrimitiveKind::Boolean {
@@ -1364,7 +1374,7 @@ fn derive_single_param(
} }
let captures = regex.captures(text)?; let captures = regex.captures(text)?;
let raw: String = match json_field_path.as_deref().filter(|path| !path.is_empty()) { let raw: String = match json_field_path.as_deref().filter(|path| !path.is_empty()) {
Some(field_path) => crate::data_value::extract_json_field(text, field_path)?, Some(field_path) => crate::engine::data_value::extract_json_field(text, field_path)?,
None => captures.name(capture_group)?.as_str().to_string(), None => captures.name(capture_group)?.as_str().to_string(),
}; };
parse_metric_primitive(&raw, *kind) parse_metric_primitive(&raw, *kind)
@@ -1437,7 +1447,7 @@ impl EngineSnapshot {
fn derive_log_params(text: &str, declarations: &[CompiledParamDeclaration]) -> BTreeMap<String, MetricPrimitive> { fn derive_log_params(text: &str, declarations: &[CompiledParamDeclaration]) -> BTreeMap<String, MetricPrimitive> {
let mut params = BTreeMap::new(); let mut params = BTreeMap::new();
for (name, regex, capture_group, json_field_path, kind, log_stream_gated) in declarations { for (name, regex, capture_group, json_field_path, kind, log_stream_gated) in declarations {
if *log_stream_gated && !crate::logfmt::is_log_stream_line(text) { if *log_stream_gated && !crate::engine::logfmt::is_log_stream_line(text) {
continue; continue;
} }
if *kind == MetricPrimitiveKind::Boolean { if *kind == MetricPrimitiveKind::Boolean {
@@ -1448,7 +1458,7 @@ fn derive_log_params(text: &str, declarations: &[CompiledParamDeclaration]) -> B
continue; continue;
}; };
let raw: String = match json_field_path.as_deref().filter(|path| !path.is_empty()) { let raw: String = match json_field_path.as_deref().filter(|path| !path.is_empty()) {
Some(field_path) => match crate::data_value::extract_json_field(text, field_path) { Some(field_path) => match crate::engine::data_value::extract_json_field(text, field_path) {
Some(value) => value, Some(value) => value,
None => continue, None => continue,
}, },
@@ -1467,7 +1477,7 @@ fn derive_log_params(text: &str, declarations: &[CompiledParamDeclaration]) -> B
fn log_entry_params(text: &str, declarations: &[CompiledParamDeclaration]) -> BTreeMap<String, MetricPrimitive> { fn log_entry_params(text: &str, declarations: &[CompiledParamDeclaration]) -> BTreeMap<String, MetricPrimitive> {
let mut params = derive_log_params(text, declarations); let mut params = derive_log_params(text, declarations);
params.insert("ottyl_fmt".to_string(), MetricPrimitive::String(crate::logfmt::detect_format(text).to_string())); params.insert("ottyl_fmt".to_string(), MetricPrimitive::String(crate::engine::logfmt::detect_format(text).to_string()));
params params
} }
@@ -2244,7 +2254,7 @@ mod tests {
assert!(declarations[0].6, "the original auto-derived declaration must be untouched"); assert!(declarations[0].6, "the original auto-derived declaration must be untouched");
} }
const LOG_STREAM_FIXTURE: &str = include_str!("fixtures/log_stream_sample.txt"); const LOG_STREAM_FIXTURE: &str = include_str!("../fixtures/log_stream_sample.txt");
fn ingest_log_stream_fixture(index: &mut EngineSnapshot) { fn ingest_log_stream_fixture(index: &mut EngineSnapshot) {
let mut header = None; let mut header = None;
+1 -1
View File
@@ -1,6 +1,6 @@
use std::collections::VecDeque; use std::collections::VecDeque;
use crate::timestamp::Timestamp; use crate::engine::timestamp::Timestamp;
fn count_in_window(points: &[(Timestamp, f64)], now: Timestamp, window_ms: i64) -> usize { fn count_in_window(points: &[(Timestamp, f64)], now: Timestamp, window_ms: i64) -> usize {
let now_ms = now.as_millis(); let now_ms = now.as_millis();
+3 -217
View File
@@ -1,224 +1,10 @@
mod alerts;
mod api; mod api;
mod cli;
mod config;
mod consts; mod consts;
mod data_value;
mod dto;
mod engine; mod engine;
mod filter_datetime;
mod filter_parser;
mod index_key;
mod ingest;
mod logfmt;
mod mcp_server;
mod metric_primitive;
mod profile;
mod promql;
mod rate;
mod sessions;
mod settings;
mod timestamp;
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, Mutex};
use clap::{Parser, Subcommand};
use tokio::net::TcpListener;
use settings::Settings;
static SETTINGS_PATH_HELP: LazyLock<String> = LazyLock::new(|| {
format!(
"Path to the settings file. Defaults to {}.",
settings::default_settings_path()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "a per-OS config location".to_string())
)
});
#[derive(Parser, Debug)]
struct Args {
#[command(subcommand)]
command: Option<Command>,
#[arg(long, default_value_t = 0)]
port: u16,
#[arg(long, help = SETTINGS_PATH_HELP.as_str())]
settings_path: Option<PathBuf>,
#[arg(long)]
file: Option<PathBuf>,
#[arg(long)]
metrics_url: Option<String>,
#[arg(long)]
name: Option<String>,
}
#[derive(Subcommand, Debug)]
enum Command {
Mcp {
#[arg(long)]
session: Option<String>,
#[arg(long)]
port: Option<u16>,
},
Sessions,
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let args = Args::parse(); cli::run().await;
let settings_path = args.settings_path.clone().unwrap_or_else(|| {
settings::default_settings_path()
.expect("could not determine a default settings path; pass --settings-path")
});
match args.command {
Some(Command::Mcp { session, port }) => {
run_mcp(&settings_path, session, port).await;
return;
}
Some(Command::Sessions) => {
print_sessions(&settings_path);
return;
}
None => {}
}
let settings = Arc::new(Mutex::new(Settings::load(&settings_path)));
let engine_tx = engine::spawn(settings.clone(), consts::ENGINE_CHANNEL_CAPACITY);
{
let max_logs = settings.lock().unwrap().max_logs;
let _ = engine_tx.tx.send(engine::EngineMessage::SetMaxLogs(max_logs)).await;
}
{
let (active_profile_name, active_profile) = {
let mut settings_guard = settings.lock().unwrap();
settings_guard
.ensure_default_profile(&settings_path)
.expect("failed to ensure the Default profile exists");
let active_profile_name = settings_guard.active_profile.clone();
let active_profile = active_profile_name
.as_deref()
.and_then(|name| settings_guard.profile(name).cloned());
(active_profile_name, active_profile)
};
match (active_profile_name, active_profile) {
(Some(_), Some(active_profile)) => {
active_profile.load_into_engine(&engine_tx).await;
}
(Some(profile_name), None) => {
eprintln!(
"warning: active profile '{profile_name}' not found in {}; skipping",
settings_path.display()
);
}
(None, _) => {}
}
}
match args.file.clone() {
Some(path) => {
tokio::spawn(ingest::run_file(engine_tx.clone(), path));
}
None => {
tokio::spawn(ingest::run(engine_tx.clone()));
}
}
if let Some(metrics_url) = args.metrics_url.clone() {
tokio::spawn(ingest::run_metrics_poll(engine_tx.clone(), metrics_url));
}
let app = api::router(engine_tx, settings, settings_path.clone());
let listener = TcpListener::bind((consts::DEFAULT_BIND_HOST, args.port))
.await
.expect("failed to bind listener");
let bound_port = listener
.local_addr()
.expect("failed to read back bound address")
.port();
let source = match &args.file {
Some(path) => format!("tailing {}", path.display()),
None => "reading logs from stdin".to_string(),
};
let session_name = args.name.clone().unwrap_or_else(sessions::generate_name);
match sessions::register_session(&settings_path, &session_name, bound_port) {
Ok(_) => eprintln!(
"ottyl serving on http://localhost:{} ({}), session name: {}",
bound_port, source, session_name
),
Err(err) => eprintln!(
"ottyl serving on http://localhost:{} ({}); warning: failed to register session '{}': {}",
bound_port, source, session_name, err
),
}
axum::serve(listener, app)
.await
.expect("server error");
}
async fn run_mcp(settings_path: &std::path::Path, session: Option<String>, port: Option<u16>) {
use rmcp::ServiceExt;
let port = match (port, session) {
(Some(port), _) => port,
(None, Some(name)) => match sessions::resolve_session(settings_path, &name) {
Some(info) => info.port,
None => {
eprintln!("error: no running ottyl session named '{name}'");
std::process::exit(1);
}
},
(None, None) => {
eprintln!("error: `ottyl mcp` requires --session <name> or --port <port>");
std::process::exit(1);
}
};
let base_url = format!("http://localhost:{port}");
let service = mcp_server::OttylMcp::new(base_url)
.serve(rmcp::transport::stdio())
.await
.expect("failed to start MCP server");
service.waiting().await.expect("MCP server error");
}
fn print_sessions(settings_path: &std::path::Path) {
let dir = sessions::sessions_dir(settings_path);
let sessions = sessions::list_sessions(&dir);
if sessions.is_empty() {
println!("no known ottyl sessions");
return;
}
for session in sessions {
println!("{}\tport={}\tpid={}", session.name, session.port, session.pid);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_path_defaults_to_none() {
let args = Args::parse_from(["ottyl"]);
assert_eq!(args.settings_path, None);
}
#[test]
fn settings_path_flag_overrides_default() {
let args = Args::parse_from(["ottyl", "--settings-path", "/tmp/custom-settings.json"]);
assert_eq!(
args.settings_path,
Some(PathBuf::from("/tmp/custom-settings.json"))
);
}
} }