tailtriage-cli 0.3.0

CLI for tailtriage artifact loading, diagnosis, and report generation
Documentation
use std::io::BufRead;
use std::path::PathBuf;

use clap::{Parser, ValueEnum};
use tailtriage_analyzer::{
    render_json_pretty, render_text, try_analyze_run, validate_artifact_strict,
};
use tailtriage_cli::artifact::load_run_artifact;
use tailtriage_cli::{analyzer_options_help_text, build_analyze_options};
use tailtriage_core::{CaptureLimitsOverride, CaptureMode, LocalJsonSink, RunSink};
use tailtriage_tracing::{
    ensure_persistable_run_has_requests, import_jsonl_path_with_mode, ImportError, ImportOptions,
    JsonlParseMode,
};

#[derive(Debug, Parser)]
#[command(name = "tailtriage")]
#[command(about = "Import and analyze tailtriage run artifacts", version)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Debug, clap::Subcommand)]
enum Command {
    /// Import completed tailtriage tracing span JSONL into a Run JSON artifact.
    Import {
        #[command(subcommand)]
        command: ImportCommand,
    },
    /// Analyze one tailtriage run JSON artifact.
    Analyze {
        /// Path to a run JSON file generated by tailtriage-core.
        #[arg(value_name = "RUN_JSON")]
        run_json: Option<PathBuf>,
        /// Output format for the diagnosis report.
        #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
        format: OutputFormat,
        /// Path to analyzer TOML config. Applied before `--analyzer-set` overrides.
        #[arg(long, value_name = "TOML")]
        analyzer_config: Option<PathBuf>,
        /// Analyzer override in `path=value` form; may be repeated.
        #[arg(long = "analyzer-set", value_name = "PATH=VALUE")]
        analyzer_set: Vec<String>,
        /// Print analyzer option help and exit successfully.
        #[arg(long)]
        help_analyzer_options: bool,
        /// Fail when request-scoped artifact invariants are ambiguous.
        #[arg(long)]
        strict_artifact: bool,
    },
}

#[derive(Debug, clap::Subcommand)]
enum ImportCommand {
    /// Import completed `tt.*` tailtriage tracing span JSONL into Run JSON.
    #[command(name = "tracing-spans-jsonl")]
    TracingSpansJsonl {
        /// Path to completed tailtriage tracing span JSONL records.
        #[arg(value_name = "SPANS_JSONL")]
        spans_jsonl: PathBuf,
        /// Service name to store in imported run metadata.
        #[arg(long, value_name = "SERVICE")]
        service: String,
        /// Path where imported run JSON should be written.
        #[arg(long, value_name = "RUN_JSON")]
        output: PathBuf,
        /// Optional service version to store in run metadata.
        #[arg(long, value_name = "VERSION")]
        service_version: Option<String>,
        /// Optional explicit run id for imported run metadata.
        #[arg(long, value_name = "RUN_ID")]
        run_id: Option<String>,
        /// Fail on malformed/incomplete tailtriage spans.
        #[arg(long)]
        strict: bool,
        /// Input format. Defaults to the stable tailtriage wrapper JSONL format.
        #[arg(long, value_enum, default_value_t = TracingInputFormat::TailtriageSpanJsonl)]
        input_format: TracingInputFormat,
        /// Import capture mode semantics for request/stage/queue retention.
        #[arg(long, value_enum, default_value_t = ImportCaptureMode::Light)]
        mode: ImportCaptureMode,
        /// Override max retained request events for imported evidence.
        #[arg(long)]
        max_requests: Option<usize>,
        /// Override max retained stage events for imported evidence.
        #[arg(long)]
        max_stages: Option<usize>,
        /// Override max retained queue events for imported evidence.
        #[arg(long)]
        max_queues: Option<usize>,
    },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum TracingInputFormat {
    /// Stable wrapper-only format: {"format":"tailtriage.tracing-span.v1","span":{...}}.
    TailtriageSpanJsonl,
    /// Compatibility parser for pre-stable/internal normalized completed-span shapes; not arbitrary tracing log JSON.
    Compatible,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum ImportCaptureMode {
    Light,
    Investigation,
}

impl From<ImportCaptureMode> for CaptureMode {
    fn from(value: ImportCaptureMode) -> Self {
        match value {
            ImportCaptureMode::Light => Self::Light,
            ImportCaptureMode::Investigation => Self::Investigation,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum OutputFormat {
    Text,
    Json,
}

fn run() -> Result<(), Box<dyn std::error::Error>> {
    let cli = Cli::parse();

    match cli.command {
        Command::Import { command } => match command {
            ImportCommand::TracingSpansJsonl {
                spans_jsonl,
                service,
                output,
                service_version,
                run_id,
                strict,
                input_format,
                mode,
                max_requests,
                max_stages,
                max_queues,
            } => import_tracing_spans_jsonl(
                spans_jsonl,
                service,
                &output,
                service_version,
                run_id,
                strict,
                input_format,
                mode,
                max_requests,
                max_stages,
                max_queues,
            )?,
        },
        Command::Analyze {
            run_json,
            format,
            analyzer_config,
            analyzer_set,
            help_analyzer_options,
            strict_artifact,
        } => {
            if help_analyzer_options {
                println!("{}", analyzer_options_help_text());
                return Ok(());
            }

            let run_json = run_json.ok_or_else(|| {
                clap::Error::raw(
                    clap::error::ErrorKind::MissingRequiredArgument,
                    "missing required RUN_JSON argument (or use --help-analyzer-options)",
                )
            })?;
            let loaded = load_run_artifact(&run_json)?;
            if strict_artifact {
                validate_artifact_strict(&loaded.run)?;
            }
            for warning in &loaded.warnings {
                eprintln!("warning: {warning}");
            }
            let options = build_analyze_options(analyzer_config.as_deref(), &analyzer_set)?;
            let report = try_analyze_run(&loaded.run, options)?;

            match format {
                OutputFormat::Text => {
                    println!("{}", render_text(&report));
                }
                OutputFormat::Json => {
                    println!("{}", render_json_pretty(&report)?);
                }
            }
        }
    }

    Ok(())
}

fn create_output_parent_dir(path: &std::path::Path) -> Result<(), std::io::Error> {
    let Some(parent) = path.parent() else {
        return Ok(());
    };
    if parent.as_os_str().is_empty() {
        return Ok(());
    }
    std::fs::create_dir_all(parent).map_err(|err| {
        std::io::Error::new(
            err.kind(),
            format!(
                "failed to create output parent directory '{}': {err}",
                parent.display()
            ),
        )
    })
}

#[allow(clippy::too_many_arguments)]
fn import_tracing_spans_jsonl(
    spans_jsonl: PathBuf,
    service: String,
    output: &PathBuf,
    service_version: Option<String>,
    run_id: Option<String>,
    strict: bool,
    input_format: TracingInputFormat,
    mode: ImportCaptureMode,
    max_requests: Option<usize>,
    max_stages: Option<usize>,
    max_queues: Option<usize>,
) -> Result<(), Box<dyn std::error::Error>> {
    if max_requests == Some(0) {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            "--max-requests must be at least 1 for persisted tracing import; tailtriage analyze requires at least one request event",
        )
        .into());
    }

    let mut options = ImportOptions::new(service).strict(strict).mode(mode.into());
    if let Some(service_version) = service_version {
        options = options.service_version(service_version);
    }
    if let Some(run_id) = run_id {
        options = options.run_id(run_id);
    }
    let mut capture_limits_override = CaptureLimitsOverride::default();
    if let Some(max_requests) = max_requests {
        capture_limits_override.max_requests = Some(max_requests);
    }
    if let Some(max_stages) = max_stages {
        capture_limits_override.max_stages = Some(max_stages);
    }
    if let Some(max_queues) = max_queues {
        capture_limits_override.max_queues = Some(max_queues);
    }
    options = options.capture_limits_override(capture_limits_override);

    let looks_like_fmt_json = if matches!(
        input_format,
        TracingInputFormat::TailtriageSpanJsonl | TracingInputFormat::Compatible
    ) {
        input_looks_like_tracing_fmt_json(&spans_jsonl).map_err(|err| {
            std::io::Error::new(
                err.kind(),
                format!(
                    "failed to inspect completed tailtriage tracing span JSONL input '{}': {err}",
                    spans_jsonl.display()
                ),
            )
        })?
    } else {
        false
    };

    if looks_like_fmt_json {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            tracing_spans_jsonl_setup_guidance(),
        )
        .into());
    }
    let parse_mode = match input_format {
        TracingInputFormat::TailtriageSpanJsonl => JsonlParseMode::TailtriageWrapperOnly,
        TracingInputFormat::Compatible => JsonlParseMode::Compatible,
    };
    let imported =
        import_jsonl_path_with_mode(spans_jsonl, options, parse_mode).map_err(|err| {
            if should_append_wrapper_guidance(input_format, &err) {
                let message = format!("{err}\n{}", wrapper_only_rejection_guidance());
                Box::<dyn std::error::Error>::from(std::io::Error::new(
                    std::io::ErrorKind::InvalidInput,
                    message,
                ))
            } else {
                Box::<dyn std::error::Error>::from(err)
            }
        })?;
    for warning in imported.warnings() {
        eprintln!("warning: {}", warning.message());
    }
    if let Err(err) = ensure_persistable_run_has_requests(imported.run()) {
        if matches!(input_format, TracingInputFormat::TailtriageSpanJsonl) {
            let message = format!("{err}\n{}", wrapper_only_rejection_guidance());
            return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, message).into());
        }
        return Err(err.into());
    }
    create_output_parent_dir(output.as_path())?;
    LocalJsonSink::new(output).write(imported.run())?;
    Ok(())
}

fn tracing_spans_jsonl_setup_guidance() -> &'static str {
    "the file looks like ordinary tracing log JSON, not completed tailtriage tracing span JSONL. tailtriage requires completed spans with literal dotted tt.* keys and explicit unix-ms start/end timestamps. Stable import expects wrapper JSONL records shaped like {\"format\":\"tailtriage.tracing-span.v1\",\"span\":{...}}. Ordinary tracing_subscriber::fmt().json() logs are unsupported. Recommended setup: TracingIntakeSession::builder(...).completed_span_jsonl_path(...). Then run: tailtriage import tracing-spans-jsonl <completed-spans.jsonl> --service <service> --output <run-json>"
}

fn should_append_wrapper_guidance(input_format: TracingInputFormat, err: &ImportError) -> bool {
    matches!(input_format, TracingInputFormat::TailtriageSpanJsonl)
        && matches!(err, ImportError::ExpectedTailtriageWrapper { .. })
}

fn wrapper_only_rejection_guidance() -> &'static str {
    "stable import expects wrapper JSONL records shaped like {\"format\":\"tailtriage.tracing-span.v1\",\"span\":{...}}. Ordinary tracing_subscriber::fmt().json() logs are unsupported. Use --input-format compatible only for pre-stable/internal normalized completed-span shapes."
}

fn input_looks_like_tracing_fmt_json(path: &std::path::Path) -> Result<bool, std::io::Error> {
    let file = std::fs::File::open(path)?;
    let reader = std::io::BufReader::new(file);
    for line in reader.lines() {
        let line = line?;
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
            let has_start = value.get("started_at_unix_ms").is_some()
                || value.get("start_unix_ms").is_some()
                || value
                    .get("span")
                    .and_then(|s| s.as_object())
                    .is_some_and(|span| {
                        span.contains_key("started_at_unix_ms")
                            || span.contains_key("start_unix_ms")
                    });
            let has_end = value.get("finished_at_unix_ms").is_some()
                || value.get("end_unix_ms").is_some()
                || value
                    .get("span")
                    .and_then(|s| s.as_object())
                    .is_some_and(|span| {
                        span.contains_key("finished_at_unix_ms") || span.contains_key("end_unix_ms")
                    });
            let has_completed_span_timing = has_start && has_end;
            let looks_like_fmt = value.get("timestamp").is_some()
                && value.get("level").is_some()
                && value.get("target").is_some();
            if looks_like_fmt && !has_completed_span_timing {
                return Ok(true);
            }
        }
        break;
    }
    Ok(false)
}

fn main() {
    if let Err(err) = run() {
        eprintln!("{err}");
        std::process::exit(1);
    }
}