use comfy_table::modifiers::UTF8_ROUND_CORNERS;
use comfy_table::presets::UTF8_FULL_CONDENSED;
use comfy_table::{Cell, CellAlignment, ContentArrangement, Table};
use is_terminal::IsTerminal;
use owo_colors::OwoColorize;
use crate::otel::query::{RelatedTelemetry, SystemStatus, TraceSummary};
use crate::otel::types::{LogSeverity, StoredLog, StoredMetric, StoredSpan};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Table,
Json,
Jsonl,
}
impl OutputFormat {
pub fn from_str_opt(s: Option<&str>) -> Self {
match s {
Some("json") => OutputFormat::Json,
Some("jsonl") => OutputFormat::Jsonl,
_ => OutputFormat::Table,
}
}
}
pub fn print_traces(traces: &[TraceSummary], format: OutputFormat) {
match format {
OutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(traces).unwrap_or_default()
);
}
OutputFormat::Jsonl => {
for t in traces {
println!("{}", serde_json::to_string(t).unwrap_or_default());
}
}
OutputFormat::Table => print_traces_table(traces),
}
}
fn print_traces_table(traces: &[TraceSummary]) {
if traces.is_empty() {
println!(" No traces found.");
return;
}
let use_color = std::io::stdout().is_terminal();
let mut table = Table::new();
table
.load_preset(UTF8_FULL_CONDENSED)
.apply_modifier(UTF8_ROUND_CORNERS)
.set_content_arrangement(ContentArrangement::Dynamic);
table.set_header(vec![
Cell::new("Trace ID").set_alignment(CellAlignment::Left),
Cell::new("Operation").set_alignment(CellAlignment::Left),
Cell::new("Services").set_alignment(CellAlignment::Left),
Cell::new("Duration").set_alignment(CellAlignment::Right),
Cell::new("Spans").set_alignment(CellAlignment::Right),
Cell::new("Status").set_alignment(CellAlignment::Center),
]);
for t in traces {
let short_id = if t.trace_id.len() > 16 {
&t.trace_id[..16]
} else {
&t.trace_id
};
let duration = format_duration_ms(t.duration_ms);
let services = t.services.join(", ");
let status_text = if use_color {
if t.has_error {
format!("{}", "ERROR".red())
} else {
format!("{}", "OK".green())
}
} else if t.has_error {
"ERROR".to_string()
} else {
"OK".to_string()
};
table.add_row(vec![
Cell::new(short_id),
Cell::new(&t.root_operation),
Cell::new(&services),
Cell::new(&duration),
Cell::new(t.span_count),
Cell::new(&status_text),
]);
}
for line in table.to_string().lines() {
println!(" {}", line);
}
}
pub fn print_spans(spans: &[StoredSpan], format: OutputFormat) {
match format {
OutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(spans).unwrap_or_default()
);
}
OutputFormat::Jsonl => {
for s in spans {
println!("{}", serde_json::to_string(s).unwrap_or_default());
}
}
OutputFormat::Table => print_spans_table(spans),
}
}
fn print_spans_table(spans: &[StoredSpan]) {
if spans.is_empty() {
println!(" No spans found.");
return;
}
let use_color = std::io::stdout().is_terminal();
let mut table = Table::new();
table
.load_preset(UTF8_FULL_CONDENSED)
.apply_modifier(UTF8_ROUND_CORNERS)
.set_content_arrangement(ContentArrangement::Dynamic);
table.set_header(vec![
Cell::new("Span ID").set_alignment(CellAlignment::Left),
Cell::new("Service").set_alignment(CellAlignment::Left),
Cell::new("Operation").set_alignment(CellAlignment::Left),
Cell::new("Duration").set_alignment(CellAlignment::Right),
Cell::new("Kind").set_alignment(CellAlignment::Center),
Cell::new("Status").set_alignment(CellAlignment::Center),
Cell::new("Events").set_alignment(CellAlignment::Center),
]);
for s in spans {
let short_id = if s.span_id.len() > 16 {
&s.span_id[..16]
} else {
&s.span_id
};
let duration = format_duration_ms(s.duration_ms);
let kind_str = format!("{:?}", s.kind);
let status_text = if use_color {
match s.status {
crate::otel::types::SpanStatus::Error => format!("{}", "ERROR".red()),
crate::otel::types::SpanStatus::Ok => format!("{}", "OK".green()),
crate::otel::types::SpanStatus::Unset => "UNSET".to_string(),
}
} else {
format!("{:?}", s.status)
};
let events_text = if s.events.is_empty() {
"-".to_string()
} else {
format!("{}", s.events.len())
};
table.add_row(vec![
Cell::new(short_id),
Cell::new(&s.service_name),
Cell::new(&s.operation_name),
Cell::new(&duration),
Cell::new(&kind_str),
Cell::new(&status_text),
Cell::new(&events_text),
]);
for event in &s.events {
let time_str = event.timestamp.format("%H:%M:%S%.3f").to_string();
let event_label = if use_color {
format!(" \u{25c6} {}", event.name.yellow())
} else {
format!(" \u{25c6} {}", event.name)
};
table.add_row(vec![
Cell::new(""),
Cell::new(""),
Cell::new(&event_label),
Cell::new(&time_str),
Cell::new(""),
Cell::new(""),
Cell::new(""),
]);
}
}
for line in table.to_string().lines() {
println!(" {}", line);
}
}
pub fn print_logs(logs: &[StoredLog], format: OutputFormat) {
match format {
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(logs).unwrap_or_default());
}
OutputFormat::Jsonl => {
for l in logs {
println!("{}", serde_json::to_string(l).unwrap_or_default());
}
}
OutputFormat::Table => print_logs_table(logs),
}
}
fn print_logs_table(logs: &[StoredLog]) {
if logs.is_empty() {
println!(" No logs found.");
return;
}
let use_color = std::io::stdout().is_terminal();
let mut table = Table::new();
table
.load_preset(UTF8_FULL_CONDENSED)
.apply_modifier(UTF8_ROUND_CORNERS)
.set_content_arrangement(ContentArrangement::Dynamic);
table.set_header(vec![
Cell::new("Time").set_alignment(CellAlignment::Left),
Cell::new("Service").set_alignment(CellAlignment::Left),
Cell::new("Level").set_alignment(CellAlignment::Center),
Cell::new("Body").set_alignment(CellAlignment::Left),
]);
for l in logs {
let time = l.timestamp.format("%H:%M:%S%.3f").to_string();
let severity_text = if use_color {
match l.severity {
LogSeverity::Error | LogSeverity::Fatal => {
format!("{}", format!("{:?}", l.severity).red())
}
LogSeverity::Warn => format!("{}", format!("{:?}", l.severity).yellow()),
LogSeverity::Debug | LogSeverity::Trace => {
format!("{}", format!("{:?}", l.severity).dimmed())
}
_ => format!("{:?}", l.severity),
}
} else {
format!("{:?}", l.severity)
};
let body = if l.body.len() > 120 {
format!("{}...", &l.body[..117])
} else {
l.body.clone()
};
table.add_row(vec![
Cell::new(&time),
Cell::new(&l.service_name),
Cell::new(&severity_text),
Cell::new(&body),
]);
}
for line in table.to_string().lines() {
println!(" {}", line);
}
}
pub fn print_metrics(metrics: &[StoredMetric], format: OutputFormat) {
match format {
OutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(metrics).unwrap_or_default()
);
}
OutputFormat::Jsonl => {
for m in metrics {
println!("{}", serde_json::to_string(m).unwrap_or_default());
}
}
OutputFormat::Table => print_metrics_table(metrics),
}
}
fn print_metrics_table(metrics: &[StoredMetric]) {
if metrics.is_empty() {
println!(" No metrics found.");
return;
}
let mut table = Table::new();
table
.load_preset(UTF8_FULL_CONDENSED)
.apply_modifier(UTF8_ROUND_CORNERS)
.set_content_arrangement(ContentArrangement::Dynamic);
table.set_header(vec![
Cell::new("Time").set_alignment(CellAlignment::Left),
Cell::new("Service").set_alignment(CellAlignment::Left),
Cell::new("Metric").set_alignment(CellAlignment::Left),
Cell::new("Type").set_alignment(CellAlignment::Center),
Cell::new("Value").set_alignment(CellAlignment::Right),
Cell::new("Unit").set_alignment(CellAlignment::Center),
]);
for m in metrics {
let time = m.timestamp.format("%H:%M:%S%.3f").to_string();
let type_str = format!("{:?}", m.metric_type);
let value_str = format_metric_value(m.value);
let unit = m.unit.as_deref().unwrap_or("-");
table.add_row(vec![
Cell::new(&time),
Cell::new(&m.service_name),
Cell::new(&m.metric_name),
Cell::new(&type_str),
Cell::new(&value_str),
Cell::new(unit),
]);
}
for line in table.to_string().lines() {
println!(" {}", line);
}
}
pub fn print_status(status: &SystemStatus, format: OutputFormat) {
match format {
OutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(status).unwrap_or_default()
);
}
OutputFormat::Jsonl | OutputFormat::Table => print_status_table(status),
}
}
fn print_status_table(status: &SystemStatus) {
let use_color = std::io::stdout().is_terminal();
println!();
if use_color {
println!(" {} OTel Collector Status", "\u{25cf}".green());
} else {
println!(" OTel Collector Status");
}
println!();
println!(" Traces: {}", status.trace_count);
println!(" Spans: {}", status.span_count);
println!(" Logs: {}", status.log_count);
println!(" Metrics: {}", status.metric_count);
println!();
if status.services.is_empty() {
println!(" No services reporting telemetry.");
} else {
println!(" Services: {}", status.services.join(", "));
}
println!();
}
pub fn print_related(related: &RelatedTelemetry, format: OutputFormat) {
match format {
OutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(related).unwrap_or_default()
);
}
OutputFormat::Jsonl => {
println!("{}", serde_json::to_string(related).unwrap_or_default());
}
OutputFormat::Table => {
if !related.logs.is_empty() {
println!(" Related Logs ({}):", related.logs.len());
print_logs(&related.logs, OutputFormat::Table);
}
if !related.metrics.is_empty() {
println!(" Related Metrics ({}):", related.metrics.len());
print_metrics(&related.metrics, OutputFormat::Table);
}
if related.logs.is_empty() && related.metrics.is_empty() {
println!(" No related telemetry found.");
}
}
}
}
fn format_duration_ms(ms: u64) -> String {
if ms < 1 {
"<1ms".to_string()
} else if ms < 1_000 {
format!("{}ms", ms)
} else if ms < 60_000 {
format!("{:.1}s", ms as f64 / 1_000.0)
} else {
let mins = ms / 60_000;
let secs = (ms % 60_000) / 1_000;
format!("{}m{}s", mins, secs)
}
}
fn format_metric_value(v: f64) -> String {
if v == v.floor() && v.abs() < 1_000_000.0 {
format!("{}", v as i64)
} else {
format!("{:.3}", v)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_duration_sub_ms() {
assert_eq!(format_duration_ms(0), "<1ms");
}
#[test]
fn format_duration_milliseconds() {
assert_eq!(format_duration_ms(42), "42ms");
assert_eq!(format_duration_ms(999), "999ms");
}
#[test]
fn format_duration_seconds() {
assert_eq!(format_duration_ms(1500), "1.5s");
assert_eq!(format_duration_ms(30_000), "30.0s");
}
#[test]
fn format_duration_minutes() {
assert_eq!(format_duration_ms(90_000), "1m30s");
}
#[test]
fn format_metric_integer() {
assert_eq!(format_metric_value(42.0), "42");
}
#[test]
fn format_metric_decimal() {
assert_eq!(format_metric_value(3.14159), "3.142");
}
#[test]
fn output_format_from_str() {
assert_eq!(OutputFormat::from_str_opt(None), OutputFormat::Table);
assert_eq!(OutputFormat::from_str_opt(Some("json")), OutputFormat::Json);
assert_eq!(
OutputFormat::from_str_opt(Some("jsonl")),
OutputFormat::Jsonl
);
assert_eq!(
OutputFormat::from_str_opt(Some("table")),
OutputFormat::Table
);
}
}