mod prune_cmd;
mod render;
mod stats_cmd;
use std::io::{self, BufRead, IsTerminal};
use std::path::{Path, PathBuf};
use chrono::Utc;
use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter;
use logdive_core::{
Indexer, InsertStats, LogEntry, LogFormat, LogdiveError, QueryOptions, Result, db_path,
execute, parse_line, parse_query,
};
use prune_cmd::{PruneArgs, run_prune};
use render::{OutputFormat, render};
use stats_cmd::{StatsArgs, run_stats};
#[derive(Parser, Debug)]
#[command(name = "logdive", version, about, long_about = None)]
struct Cli {
#[arg(long, global = true, value_name = "PATH", env = "LOGDIVE_DB")]
db: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
Ingest(IngestArgs),
Query(QueryArgs),
Stats(StatsArgs),
Prune(PruneArgs),
}
#[derive(clap::Args, Debug)]
pub struct IngestArgs {
#[arg(long, short = 'f', value_name = "PATH")]
file: Option<PathBuf>,
#[arg(long, short = 't', value_name = "TAG")]
tag: Option<String>,
#[arg(
long,
value_name = "FORMAT",
default_value = "json",
value_parser = parse_log_format
)]
format: LogFormat,
#[arg(long)]
timestamp_now: bool,
#[arg(long, requires = "file")]
follow: bool,
#[arg(long, value_name = "N", hide = true)]
max_events: Option<usize>,
}
#[derive(clap::Args, Debug)]
struct QueryArgs {
query: String,
#[arg(long, value_enum, default_value_t = OutputFormat::Pretty)]
output: OutputFormat,
#[arg(long, default_value_t = 1000)]
limit: usize,
#[arg(long, default_value_t = 0)]
offset: usize,
}
fn parse_log_format(s: &str) -> std::result::Result<LogFormat, String> {
LogFormat::from_name(s)
.ok_or_else(|| format!("unknown format {s:?}; expected one of: json, logfmt, plain"))
}
fn main() {
init_tracing();
let cli = Cli::parse();
let db = db_path(cli.db.as_deref());
let result = match cli.command {
Command::Ingest(args) => handle_ingest(&db, args),
Command::Query(args) => handle_query(&db, args),
Command::Stats(args) => run_stats(&db, args),
Command::Prune(args) => run_prune(&db, args),
};
if let Err(e) = result {
eprintln!("error: {e}");
std::process::exit(1);
}
}
fn handle_ingest(db: &Path, args: IngestArgs) -> Result<()> {
if args.follow {
#[cfg(not(unix))]
return Err(LogdiveError::IoBare(io::Error::other(
"--follow is only supported on Unix (Linux / macOS)",
)));
#[cfg(unix)]
{
let path = args
.file
.as_deref()
.expect("clap `requires = \"file\"` ensures --file is always set with --follow");
return run_watch_loop(path, db, &args);
}
}
let mut indexer = Indexer::open(db)?;
let mut total = InsertStats::default();
let mut malformed: usize = 0;
let is_tty = io::stderr().is_terminal();
if let Some(ref path) = args.file {
let file = std::fs::File::open(path).map_err(|e| LogdiveError::io_at(path, e))?;
let reader = io::BufReader::new(file);
ingest_reader(
reader,
&mut indexer,
&args,
&mut total,
&mut malformed,
is_tty,
)?;
} else {
let stdin = io::stdin();
let reader = stdin.lock();
ingest_reader(
reader,
&mut indexer,
&args,
&mut total,
&mut malformed,
is_tty,
)?;
}
if is_tty {
eprintln!();
}
print_ingest_summary(total, malformed);
Ok(())
}
fn ingest_reader<R: BufRead>(
reader: R,
indexer: &mut Indexer,
args: &IngestArgs,
total: &mut InsertStats,
malformed: &mut usize,
is_tty: bool,
) -> Result<()> {
const BATCH: usize = 1000;
let mut batch: Vec<LogEntry> = Vec::with_capacity(BATCH);
for line_result in reader.lines() {
let line = line_result.map_err(LogdiveError::IoBare)?;
if line.trim().is_empty() {
continue;
}
match parse_line(args.format, &line) {
Some(mut entry) => {
if entry.timestamp.is_none() && args.timestamp_now {
entry.timestamp = Some(Utc::now().to_rfc3339());
}
entry = entry.with_tag(args.tag.as_deref());
batch.push(entry);
if batch.len() >= BATCH {
let stats = indexer.insert_batch(&batch)?;
accumulate(total, &stats);
batch.clear();
if is_tty {
eprint!(
"\r {} ingested {} dedup {} skipped",
total.inserted, total.deduplicated, total.skipped_no_timestamp
);
}
}
}
None => *malformed += 1,
}
}
if !batch.is_empty() {
let stats = indexer.insert_batch(&batch)?;
accumulate(total, &stats);
}
Ok(())
}
fn accumulate(total: &mut InsertStats, delta: &InsertStats) {
total.inserted += delta.inserted;
total.deduplicated += delta.deduplicated;
total.skipped_no_timestamp += delta.skipped_no_timestamp;
}
fn print_ingest_summary(stats: InsertStats, malformed: usize) {
eprintln!(
"ingested {} dedup {} no-timestamp {} malformed {}",
stats.inserted, stats.deduplicated, stats.skipped_no_timestamp, malformed
);
}
#[cfg(unix)]
fn run_watch_loop(path: &Path, db: &Path, args: &IngestArgs) -> Result<()> {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::RecvTimeoutError;
use std::time::Duration;
use notify::{RecursiveMode, Watcher, recommended_watcher};
use logdive_core::FileTailer;
let mut tailer = FileTailer::open(path)?;
let (tx, rx) = std::sync::mpsc::channel::<notify::Result<notify::Event>>();
let mut watcher = recommended_watcher(move |res| {
let _ = tx.send(res);
})
.map_err(|e| LogdiveError::IoBare(io::Error::other(e.to_string())))?;
watcher
.watch(path, RecursiveMode::NonRecursive)
.map_err(|e| LogdiveError::IoBare(io::Error::other(e.to_string())))?;
let stop = Arc::new(AtomicBool::new(false));
let stop_clone = Arc::clone(&stop);
ctrlc::set_handler(move || stop_clone.store(true, Ordering::SeqCst))
.map_err(|e| LogdiveError::IoBare(io::Error::other(e.to_string())))?;
let mut indexer = Indexer::open(db)?;
let mut total = InsertStats::default();
let mut malformed: usize = 0;
let mut event_count: usize = 0;
eprintln!("following {} — Ctrl-C to stop", path.display());
while !stop.load(Ordering::SeqCst) {
match rx.recv_timeout(Duration::from_secs(1)) {
Ok(_event) => {
let lines = tailer.read_new_lines()?;
ingest_lines(&lines, &mut indexer, args, &mut total, &mut malformed)?;
event_count += 1;
if let Some(max) = args.max_events {
if event_count >= max {
break;
}
}
}
Err(RecvTimeoutError::Timeout) => {
let lines = tailer.read_new_lines()?;
if !lines.is_empty() {
ingest_lines(&lines, &mut indexer, args, &mut total, &mut malformed)?;
}
}
Err(RecvTimeoutError::Disconnected) => break,
}
}
print_ingest_summary(total, malformed);
Ok(())
}
#[cfg(unix)]
fn ingest_lines(
lines: &[String],
indexer: &mut Indexer,
args: &IngestArgs,
total: &mut InsertStats,
malformed: &mut usize,
) -> Result<()> {
let mut batch: Vec<LogEntry> = Vec::with_capacity(lines.len());
for line in lines {
if line.trim().is_empty() {
continue;
}
match parse_line(args.format, line) {
Some(mut entry) => {
if entry.timestamp.is_none() && args.timestamp_now {
entry.timestamp = Some(Utc::now().to_rfc3339());
}
entry = entry.with_tag(args.tag.as_deref());
batch.push(entry);
}
None => *malformed += 1,
}
}
if !batch.is_empty() {
let stats = indexer.insert_batch(&batch)?;
accumulate(total, &stats);
}
Ok(())
}
fn handle_query(db: &Path, args: QueryArgs) -> Result<()> {
let ast = parse_query(&args.query)?;
let opts = QueryOptions {
limit: match args.limit {
0 => None,
n => Some(n),
},
offset: match args.offset {
0 => None,
n => Some(n),
},
};
let indexer = Indexer::open(db)?;
let entries = execute(&ast, indexer.connection(), opts)?;
render(&entries, args.output)
}
fn init_tracing() {
let filter = EnvFilter::try_from_env("LOGDIVE_LOG").unwrap_or_else(|_| EnvFilter::new("warn"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(io::stderr)
.init();
}