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);
}
}