use crate::cli::ColoringMode;
use crate::processing::TelemetryData;
use anyhow::Result;
use chrono::{TimeZone, Utc};
use colored::*;
use comfy_table::{
presets, Attribute, Cell, CellAlignment, ColumnConstraint, ContentArrangement, Table,
TableComponent, Width::Fixed,
};
use globset::GlobSet;
use opentelemetry_proto::tonic::{
collector::trace::v1::ExportTraceServiceRequest,
common::v1::{any_value::Value as ProtoValue, AnyValue, KeyValue},
trace::v1::{status, Span},
};
use prost::Message;
use std::collections::HashMap;
use std::str::FromStr; use terminal_size::{self, Height, Width};
const SERVICE_NAME_WIDTH: usize = 25;
const SPAN_NAME_WIDTH: usize = 40;
const SPAN_ID_WIDTH: usize = 10;
const SPAN_KIND_WIDTH: usize = 10; const STATUS_WIDTH: usize = 8; const DURATION_WIDTH: usize = 13;
const ERROR_COLOR: (u8, u8, u8) = (255, 0, 0);
const SERVICE_COLORS: [(u8, u8, u8); 12] = [
(46, 134, 193), (142, 68, 173), (39, 174, 96), (41, 128, 185), (23, 165, 137), (40, 116, 166), (156, 89, 182), (52, 152, 219), (26, 188, 156), (22, 160, 133), (106, 90, 205), (52, 73, 94), ];
const TABLEAU_12: [(u8, u8, u8); 12] = [
(31, 119, 180), (255, 127, 14), (44, 160, 44), (214, 39, 40), (148, 103, 189), (140, 86, 75), (227, 119, 194), (127, 127, 127), (188, 189, 34), (23, 190, 207), (199, 199, 199), (255, 187, 120), ];
const COLORBREWER_SET3_12: [(u8, u8, u8); 12] = [
(141, 211, 199), (255, 255, 179), (190, 186, 218), (251, 128, 114), (128, 177, 211), (253, 180, 98), (179, 222, 105), (252, 205, 229), (217, 217, 217), (188, 128, 189), (204, 235, 197), (255, 237, 111), ];
const MATERIAL_12: [(u8, u8, u8); 12] = [
(244, 67, 54), (233, 30, 99), (156, 39, 176), (103, 58, 183), (63, 81, 181), (33, 150, 243), (0, 188, 212), (0, 150, 136), (76, 175, 80), (205, 220, 57), (255, 152, 0), (121, 85, 72), ];
const SOLARIZED_12: [(u8, u8, u8); 12] = [
(38, 139, 210), (211, 54, 130), (42, 161, 152), (133, 153, 0), (203, 75, 22), (220, 50, 47), (181, 137, 0), (108, 113, 196), (147, 161, 161), (101, 123, 131), (238, 232, 213), (7, 54, 66), ];
const MONOCHROME_12: [(u8, u8, u8); 12] = [
(100, 100, 100),
(115, 115, 115),
(130, 130, 130),
(145, 145, 145),
(160, 160, 160),
(175, 175, 175),
(90, 90, 90),
(105, 105, 105),
(120, 120, 120),
(135, 135, 135),
(150, 150, 150),
(165, 165, 165),
];
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Theme {
Default,
Tableau,
ColorBrewer,
Material,
Solarized,
Monochrome,
}
impl FromStr for Theme {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"default" => Ok(Theme::Default),
"tableau" => Ok(Theme::Tableau),
"colorbrewer" => Ok(Theme::ColorBrewer),
"material" => Ok(Theme::Material),
"solarized" => Ok(Theme::Solarized),
"monochrome" => Ok(Theme::Monochrome),
_ => Err(format!("Invalid theme name: {}", s)),
}
}
}
impl Theme {
pub fn get_palette(&self) -> &'static [(u8, u8, u8); 12] {
match self {
Theme::Default => &SERVICE_COLORS,
Theme::Tableau => &TABLEAU_12,
Theme::ColorBrewer => &COLORBREWER_SET3_12,
Theme::Material => &MATERIAL_12,
Theme::Solarized => &SOLARIZED_12,
Theme::Monochrome => &MONOCHROME_12,
}
}
pub fn get_color_for_service(&self, service_name: &str) -> (u8, u8, u8) {
let service_hash = service_name.chars().fold(0, |acc, c| acc + (c as usize));
let palette = self.get_palette();
palette[service_hash % palette.len()]
}
pub fn get_color_for_span(&self, span_id: &str) -> (u8, u8, u8) {
let mut span_hash: usize = 5381; for c in span_id.chars() {
span_hash = span_hash.wrapping_add(c as usize).wrapping_mul(33); }
let palette = self.get_palette();
palette[span_hash % palette.len()]
}
pub fn is_valid_theme(theme_name: &str) -> bool {
matches!(
theme_name.to_lowercase().as_str(),
"default" | "tableau" | "colorbrewer" | "material" | "solarized" | "monochrome"
)
}
}
#[derive(Debug, Clone)]
struct ConsoleSpan {
id: String,
#[allow(dead_code)]
parent_id: Option<String>,
name: String,
start_time: u64,
duration_ns: u64,
children: Vec<ConsoleSpan>,
status_code: status::StatusCode,
service_name: String,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum ItemType {
SpanStart,
Event,
}
#[derive(Debug)]
struct TimelineItem {
timestamp_ns: u64,
item_type: ItemType,
service_name: String,
span_id: String,
name: String, level_or_status: String, is_error: bool, attributes: Vec<KeyValue>, parent_span_attributes: Option<Vec<KeyValue>>,
}
pub fn get_terminal_width(default_width: usize) -> usize {
if let Some((Width(w), Height(_h))) = terminal_size::terminal_size() {
w as usize
} else {
default_width }
}
fn format_duration_for_scale(duration_ns: u64) -> String {
if duration_ns == 0 {
return "▾0ms".to_string();
}
let ms = duration_ns as f64 / 1_000_000.0;
if ms < 1.0 {
let us = duration_ns as f64 / 1_000.0;
format!("▾{:.0}μs", us)
} else if ms < 1000.0 {
format!("▾{:.0}ms", ms)
} else {
format!("▾{:.1}s", ms / 1000.0)
}
}
fn generate_timeline_scale(trace_duration_ns: u64, timeline_width: usize) -> String {
if trace_duration_ns == 0 || timeline_width < 10 {
return " ".repeat(timeline_width);
}
const NUM_MARKERS: usize = 5;
let mut buffer = vec![' '; timeline_width];
for i in 0..NUM_MARKERS {
let percentage = i as f64 / (NUM_MARKERS - 1) as f64;
let marker_time_ns = (trace_duration_ns as f64 * percentage).round() as u64;
let label = format_duration_for_scale(marker_time_ns);
let position = (percentage * (timeline_width - 1) as f64).round() as usize;
let label_len = label.chars().count();
let label_start = if i == 0 {
0
} else if i == NUM_MARKERS - 1 {
timeline_width.saturating_sub(label_len)
} else {
position
.saturating_sub(label_len / 2)
.min(timeline_width.saturating_sub(label_len))
};
for (j, ch) in label.chars().enumerate() {
let idx = label_start + j;
if idx < timeline_width {
buffer[idx] = ch;
}
}
}
buffer.into_iter().collect()
}
pub fn display_console(
batch: &[TelemetryData],
attr_globs: &Option<GlobSet>,
event_severity_attribute_name: &str,
theme: Theme,
color_by: ColoringMode,
events_only: bool,
root_span_received: bool, ) -> Result<()> {
tracing::debug!("Display console called with theme={:?}, color_by={:?}, events_only={}, root_span_received={}",
theme, color_by, events_only, root_span_received);
let mut spans_with_service: Vec<(Span, String)> = Vec::new();
for item in batch {
match ExportTraceServiceRequest::decode(item.payload.as_slice()) {
Ok(request) => {
for resource_span in request.resource_spans {
let service_name = find_service_name(
resource_span
.resource
.as_ref()
.map_or(&[], |r| &r.attributes),
);
for scope_span in resource_span.scope_spans {
for span in scope_span.spans {
spans_with_service.push((span.clone(), service_name.clone()));
}
}
}
}
Err(e) => {
tracing::warn!(error = %e, "Failed to decode payload for console display, skipping item.");
}
}
}
if spans_with_service.is_empty() {
return Ok(());
}
let mut traces: HashMap<String, Vec<(Span, String)>> = HashMap::new();
for (span, service_name) in spans_with_service {
let trace_id_hex = hex::encode(&span.trace_id);
traces
.entry(trace_id_hex)
.or_default()
.push((span, service_name));
}
const SPACING: usize = 6;
let fixed_width_excluding_timeline = SERVICE_NAME_WIDTH
+ SPAN_NAME_WIDTH
+ SPAN_KIND_WIDTH
+ DURATION_WIDTH
+ SPAN_ID_WIDTH
+ STATUS_WIDTH
+ SPACING;
let terminal_width = get_terminal_width(120); let calculated_timeline_width = terminal_width
.saturating_sub(fixed_width_excluding_timeline)
.max(10);
let total_table_width = terminal_width;
for (trace_id, spans_in_trace_with_service) in traces {
let base_heading = format!("Trace ID: {}", trace_id);
let suffix = if root_span_received {
""
} else {
" (Missing Root)"
};
let visible_heading_len = base_heading.len() + suffix.len();
let styled_heading = format!("{}{}", base_heading.bold(), suffix.dimmed());
let total_dash_len = total_table_width.saturating_sub(visible_heading_len + 2);
let left_dashes = 1;
let right_dashes = total_dash_len.saturating_sub(left_dashes);
println!(
"\n{} {} {}\n\n",
"─".repeat(left_dashes).dimmed(),
styled_heading, "─".repeat(right_dashes).dimmed()
);
if spans_in_trace_with_service.is_empty() {
continue;
}
let mut timeline_items: Vec<TimelineItem> = Vec::new();
for (span, service_name) in &spans_in_trace_with_service {
let span_id_hex = hex::encode(&span.span_id);
let status_code = span.status.as_ref().map_or(status::StatusCode::Unset, |s| {
status::StatusCode::try_from(s.code).unwrap_or(status::StatusCode::Unset)
});
let is_error = status_code == status::StatusCode::Error;
if !events_only {
let filtered_span_attrs: Vec<KeyValue> = match attr_globs {
Some(globs) => span
.attributes
.iter()
.filter(|kv| globs.is_match(&kv.key))
.cloned()
.collect(),
None => span.attributes.clone(), };
timeline_items.push(TimelineItem {
timestamp_ns: span.start_time_unix_nano,
item_type: ItemType::SpanStart,
service_name: service_name.clone(),
span_id: span_id_hex.clone(),
name: span.name.clone(),
level_or_status: format_span_status(status_code),
is_error,
attributes: filtered_span_attrs,
parent_span_attributes: None, });
}
for event in &span.events {
let mut level = if is_error {
"ERROR".to_string()
} else {
"INFO".to_string()
};
for attr in &event.attributes {
if attr.key == event_severity_attribute_name {
if let Some(val) = &attr.value {
if let Some(opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue(s)) = &val.value {
level = s.clone().to_uppercase();
break;
}
}
}
}
let filtered_event_attrs: Vec<KeyValue> = match attr_globs {
Some(globs) => event
.attributes
.iter()
.filter(|kv| globs.is_match(&kv.key))
.cloned()
.collect(),
None => event.attributes.clone(),
};
let filtered_parent_span_attrs: Vec<KeyValue> = match attr_globs {
Some(globs) => span
.attributes
.iter()
.filter(|kv| globs.is_match(&kv.key))
.cloned()
.collect(),
None => span.attributes.clone(),
};
timeline_items.push(TimelineItem {
timestamp_ns: event.time_unix_nano,
item_type: ItemType::Event,
service_name: service_name.clone(),
span_id: span_id_hex.clone(),
name: event.name.clone(),
level_or_status: level,
is_error,
attributes: filtered_event_attrs,
parent_span_attributes: Some(filtered_parent_span_attrs),
});
}
}
timeline_items.sort_by_key(|item| item.timestamp_ns);
let mut span_map: HashMap<String, Span> = HashMap::new();
let mut service_name_map: HashMap<String, String> = HashMap::new();
let mut parent_to_children_map: HashMap<String, Vec<String>> = HashMap::new();
let mut root_ids: Vec<String> = Vec::new();
for (span, service_name) in spans_in_trace_with_service {
let span_id_hex = hex::encode(&span.span_id);
span_map.insert(span_id_hex.clone(), span);
service_name_map.insert(span_id_hex.clone(), service_name);
}
for (span_id_hex, span) in &span_map {
let parent_id_hex = if span.parent_span_id.is_empty() {
None
} else {
Some(hex::encode(&span.parent_span_id))
};
match parent_id_hex {
Some(ref p_id) if span_map.contains_key(p_id) => {
parent_to_children_map
.entry(p_id.clone())
.or_default()
.push(span_id_hex.clone());
}
_ => {
root_ids.push(span_id_hex.clone());
}
}
}
let mut roots: Vec<ConsoleSpan> = root_ids
.iter()
.map(|root_id| {
build_console_span(
root_id,
&span_map,
&parent_to_children_map,
&service_name_map,
)
})
.collect();
roots.sort_by_key(|s| s.start_time);
let min_start_time = roots.iter().map(|r| r.start_time).min().unwrap_or(0);
let max_end_time = span_map
.values()
.map(|s| s.end_time_unix_nano)
.max()
.unwrap_or(0);
let trace_duration_ns = max_end_time.saturating_sub(min_start_time);
let mut table = Table::new();
table
.load_preset(presets::NOTHING)
.set_content_arrangement(ContentArrangement::DynamicFullWidth)
.set_width(total_table_width as u16)
.set_style(TableComponent::MiddleHeaderIntersections, '┴')
.set_style(TableComponent::BottomBorder, '─')
.set_style(TableComponent::BottomBorderIntersections, '─')
.set_style(TableComponent::HeaderLines, '─');
table.set_header(vec![
Cell::new("Service").add_attribute(Attribute::Bold),
Cell::new("Span Name").add_attribute(Attribute::Bold),
Cell::new("Kind").add_attribute(Attribute::Bold),
Cell::new("Duration (ms)").add_attribute(Attribute::Bold),
Cell::new("Span ID").add_attribute(Attribute::Bold),
Cell::new("Status").add_attribute(Attribute::Bold),
Cell::new("Timeline").add_attribute(Attribute::Bold),
]);
let column_widths: [(usize, u16); 6] = [
(0, SERVICE_NAME_WIDTH as u16),
(1, SPAN_NAME_WIDTH as u16),
(2, SPAN_KIND_WIDTH as u16),
(3, DURATION_WIDTH as u16),
(4, SPAN_ID_WIDTH as u16),
(5, STATUS_WIDTH as u16),
];
for (index, width) in &column_widths {
if let Some(column) = table.column_mut(*index) {
column.set_constraint(ColumnConstraint::UpperBoundary(Fixed(*width)));
}
}
if trace_duration_ns > 0 {
let scale_content =
generate_timeline_scale(trace_duration_ns, calculated_timeline_width);
if !scale_content.trim().is_empty() {
table.add_row(vec![
Cell::new(""), Cell::new(""), Cell::new(""), Cell::new(""), Cell::new(""), Cell::new(""), Cell::new(scale_content), ]);
}
}
for root in roots {
add_span_to_table(
&mut table,
&root,
0,
min_start_time,
trace_duration_ns,
calculated_timeline_width,
theme,
&span_map,
color_by,
)?;
}
println!("{}", table);
if !timeline_items.is_empty() {
for item in timeline_items {
let timestamp = Utc.timestamp_nanos(item.timestamp_ns as i64);
let formatted_time = timestamp.format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string();
let (prefix_r, prefix_g, prefix_b) = match color_by {
ColoringMode::Service => theme.get_color_for_service(&item.service_name),
ColoringMode::Span => theme.get_color_for_span(&item.span_id),
};
let span_id_prefix = item.span_id.chars().take(8).collect::<String>();
let colored_span_id_prefix = if item.is_error {
span_id_prefix
.truecolor(ERROR_COLOR.0, ERROR_COLOR.1, ERROR_COLOR.2)
.to_string()
} else {
span_id_prefix
.truecolor(prefix_r, prefix_g, prefix_b)
.to_string()
};
let type_tag = match item.item_type {
ItemType::SpanStart => "[SPAN]".to_string(),
ItemType::Event => "[EVENT]".to_string(),
};
let colored_level_status = match item.level_or_status.to_uppercase().as_str() {
"ERROR" => item
.level_or_status
.truecolor(ERROR_COLOR.0, ERROR_COLOR.1, ERROR_COLOR.2)
.bold(),
"WARN" | "WARNING" => item.level_or_status.yellow().bold(),
"OK" => item.level_or_status.green(), "UNSET" => item.level_or_status.dimmed(), _ => item.level_or_status.bright_black().bold(), };
let level_status_tag = format!("[{}]", colored_level_status);
let mut attrs_to_display: Vec<String> = Vec::new();
for attr in &item.attributes {
attrs_to_display.push(format_keyvalue(attr));
}
if let Some(parent_attrs) = &item.parent_span_attributes {
for attr in parent_attrs {
let value_str = format_anyvalue(&attr.value);
attrs_to_display.push(format!(
"{}: {}",
attr.key.bright_black(),
value_str
));
}
}
let attrs_suffix = if !attrs_to_display.is_empty() {
format!(" - {}", attrs_to_display.join(", "))
} else {
String::new()
};
println!(
"{} {} [{}] {} {} {}{}",
formatted_time.bright_black(),
colored_span_id_prefix,
item.service_name,
type_tag,
level_status_tag,
item.name,
attrs_suffix
);
}
}
}
Ok(())
}
fn find_service_name(attrs: &[KeyValue]) -> String {
attrs
.iter()
.find(|kv| kv.key == "service.name")
.and_then(|kv| {
kv.value.as_ref().and_then(|av| {
if let Some(
opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue(s),
) = &av.value
{
Some(s.clone())
} else {
None
}
})
})
.unwrap_or_else(|| "<unknown>".to_string())
}
fn build_console_span(
span_id: &str,
span_map: &HashMap<String, Span>,
parent_to_children_map: &HashMap<String, Vec<String>>,
service_name_map: &HashMap<String, String>,
) -> ConsoleSpan {
let span = span_map.get(span_id).expect("Span ID should exist in map");
let service_name = service_name_map
.get(span_id)
.cloned()
.unwrap_or_else(|| "<unknown>".to_string());
let start_time = span.start_time_unix_nano;
let end_time = span.end_time_unix_nano;
let duration_ns = end_time.saturating_sub(start_time);
let status_code = span.status.as_ref().map_or(status::StatusCode::Unset, |s| {
status::StatusCode::try_from(s.code).unwrap_or(status::StatusCode::Unset)
});
let child_ids = parent_to_children_map
.get(span_id)
.cloned()
.unwrap_or_default();
let mut children: Vec<ConsoleSpan> = child_ids
.iter()
.map(|child_id| {
build_console_span(child_id, span_map, parent_to_children_map, service_name_map)
})
.collect();
children.sort_by_key(|c| c.start_time);
ConsoleSpan {
id: hex::encode(&span.span_id),
parent_id: if span.parent_span_id.is_empty() {
None
} else {
Some(hex::encode(&span.parent_span_id))
},
name: span.name.clone(),
start_time,
duration_ns,
children,
status_code,
service_name,
}
}
#[allow(clippy::too_many_arguments)]
fn add_span_to_table(
table: &mut Table,
node: &ConsoleSpan,
depth: usize,
trace_start_time_ns: u64,
trace_duration_ns: u64,
timeline_width: usize,
theme: Theme,
span_map: &HashMap<String, Span>,
color_by: ColoringMode,
) -> Result<()> {
let indent = " ".repeat(depth);
let (r, g, b) = match color_by {
ColoringMode::Service => theme.get_color_for_service(&node.service_name),
ColoringMode::Span => theme.get_color_for_span(&node.id),
};
let service_name_content = node
.service_name
.chars()
.take(SERVICE_NAME_WIDTH)
.collect::<String>();
let span_name_width = SPAN_NAME_WIDTH.saturating_sub(indent.len());
let truncated_span_name = node.name.chars().take(span_name_width).collect::<String>();
let span_name_cell_content = format!("{} {}", indent, truncated_span_name);
let duration_ms = node.duration_ns as f64 / 1_000_000.0;
let duration_content = format!("{:.2}", duration_ms);
let bar_cell_content = render_bar(
node.start_time,
node.duration_ns,
trace_start_time_ns,
trace_duration_ns,
timeline_width,
(r, g, b), );
let span_obj = span_map.get(&node.id);
let kind_cell_content = span_obj
.map_or("UNKNOWN".to_string(), |span| format_span_kind(span.kind))
.chars()
.take(SPAN_KIND_WIDTH)
.collect::<String>();
let span_id_prefix = node.id.chars().take(8).collect::<String>();
let colored_span_id_str = if node.status_code == status::StatusCode::Error {
span_id_prefix
.truecolor(ERROR_COLOR.0, ERROR_COLOR.1, ERROR_COLOR.2)
.to_string()
} else {
span_id_prefix.truecolor(r, g, b).to_string()
};
let status_content_str = format_span_status(node.status_code);
table.add_row(vec![
Cell::new(service_name_content),
Cell::new(span_name_cell_content),
Cell::new(kind_cell_content),
Cell::new(duration_content).set_alignment(CellAlignment::Right), Cell::new(colored_span_id_str),
Cell::new(status_content_str),
Cell::new(bar_cell_content),
]);
let mut children = node.children.clone();
children.sort_by_key(|c| c.start_time);
for child in &children {
add_span_to_table(
table,
child,
depth + 1,
trace_start_time_ns,
trace_duration_ns,
timeline_width,
theme,
span_map,
color_by,
)?;
}
Ok(())
}
fn render_bar(
start_time_ns: u64,
duration_ns: u64,
trace_start_time_ns: u64,
trace_duration_ns: u64,
timeline_width: usize,
service_color: (u8, u8, u8),
) -> String {
if trace_duration_ns == 0 {
return " ".repeat(timeline_width);
}
let timeline_width_f = timeline_width as f64;
let offset_ns = start_time_ns.saturating_sub(trace_start_time_ns);
let offset_fraction = offset_ns as f64 / trace_duration_ns as f64;
let duration_fraction = duration_ns as f64 / trace_duration_ns as f64;
let start_pos = (offset_fraction * timeline_width_f).floor() as usize;
let end_pos = ((offset_fraction + duration_fraction) * timeline_width_f).ceil() as usize;
let mut bar_content = String::with_capacity(timeline_width);
for i in 0..timeline_width {
if i >= start_pos && i < end_pos.min(timeline_width) {
bar_content.push('▄');
} else {
bar_content.push(' ');
}
}
let (r, g, b) = service_color;
bar_content.truecolor(r, g, b).to_string()
}
fn format_span_kind(kind: i32) -> String {
match kind {
1 => "INTERNAL".to_string(),
2 => "SERVER".to_string(),
3 => "CLIENT".to_string(),
4 => "PRODUCER".to_string(),
5 => "CONSUMER".to_string(),
_ => "UNSPECIFIED".to_string(),
}
}
fn format_keyvalue(kv: &KeyValue) -> String {
let value_str = format_anyvalue(&kv.value);
format!("{}: {}", kv.key.bright_black(), value_str)
}
fn format_anyvalue(av: &Option<AnyValue>) -> String {
match av {
Some(any_value) => match &any_value.value {
Some(ProtoValue::StringValue(s)) => s.clone(),
Some(ProtoValue::BoolValue(b)) => b.to_string(),
Some(ProtoValue::IntValue(i)) => i.to_string(),
Some(ProtoValue::DoubleValue(d)) => d.to_string(),
Some(ProtoValue::ArrayValue(_)) => "[array]".to_string(),
Some(ProtoValue::KvlistValue(_)) => "[kvlist]".to_string(),
Some(ProtoValue::BytesValue(_)) => "[bytes]".to_string(),
None => "<empty_value>".to_string(),
},
None => "<no_value>".to_string(),
}
}
fn format_span_status(status_code: status::StatusCode) -> String {
match status_code {
status::StatusCode::Ok => "OK".green().to_string(),
status::StatusCode::Error => "ERROR"
.truecolor(ERROR_COLOR.0, ERROR_COLOR.1, ERROR_COLOR.2)
.bold()
.to_string(),
status::StatusCode::Unset => "UNSET".dimmed().to_string(),
}
}