use crate::cli::ColoringMode;
use crate::processing::TelemetryData;
use anyhow::Result;
use chrono::{TimeZone, Utc};
use colored::*;
use comfy_table::{
presets, Attribute, Cell, CellAlignment, Color as TableColor, 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 regex::Regex;
use std::collections::HashMap;
use terminal_size::{self, Height, Width};
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
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 = 9; const DURATION_WIDTH: usize = 13;
const DEFAULT_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), (100, 100, 100), (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] = [
(100, 180, 100), (180, 100, 180), (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), (100, 100, 180), (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, Eq, ValueEnum, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum Theme {
#[default] Default,
Tableau,
ColorBrewer,
Material,
Solarized,
Monochrome,
}
impl std::fmt::Display for Theme {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Theme::Default => write!(f, "default"),
Theme::Tableau => write!(f, "tableau"),
Theme::ColorBrewer => write!(f, "color-brewer"),
Theme::Material => write!(f, "material"),
Theme::Solarized => write!(f, "solarized"),
Theme::Monochrome => write!(f, "monochrome"),
}
}
}
impl Theme {
const FNV_OFFSET_BASIS: usize = 2166136261;
const FNV_PRIME: usize = 16777619;
fn fnv1a_hash_str(input: &str) -> usize {
let mut hash = Self::FNV_OFFSET_BASIS;
for byte in input.as_bytes() {
hash ^= *byte as usize;
hash = hash.wrapping_mul(Self::FNV_PRIME);
}
hash
}
pub fn get_palette(&self) -> &'static [(u8, u8, u8); 12] {
match self {
Theme::Default => &DEFAULT_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 = Self::fnv1a_hash_str(service_name);
let palette = self.get_palette();
palette[service_hash % palette.len()]
}
pub fn get_color_for_span(&self, span_id: &str) -> (u8, u8, u8) {
let span_hash = Self::fnv1a_hash_str(span_id);
let palette = self.get_palette();
palette[span_hash % palette.len()]
}
}
#[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, 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()
}
fn get_string_value_for_grep(value_opt: &Option<AnyValue>) -> String {
if let Some(any_value) = value_opt {
if let Some(ref val_type) = any_value.value {
return match val_type {
ProtoValue::StringValue(s) => s.clone(),
ProtoValue::BoolValue(b) => b.to_string(),
ProtoValue::IntValue(i) => i.to_string(),
ProtoValue::DoubleValue(d) => d.to_string(),
ProtoValue::ArrayValue(arr) => arr
.values
.iter()
.map(|v_val| get_string_value_for_grep(&Some(v_val.clone()))) .collect::<Vec<String>>()
.join(", "),
ProtoValue::KvlistValue(kv_list) => kv_list
.values
.iter()
.map(|kv| format!("{}:{}", kv.key, get_string_value_for_grep(&kv.value)))
.collect::<Vec<String>>()
.join(", "),
ProtoValue::BytesValue(b) => format!("bytes_len:{}", b.len()),
};
}
}
String::new()
}
fn prepare_trace_data_from_batch(
batch: &[TelemetryData],
) -> Result<HashMap<String, Vec<(Span, String)>>> {
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(HashMap::new());
}
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));
}
Ok(traces)
}
fn calculate_layout_widths(default_terminal_width: usize) -> (usize, usize, usize) {
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(default_terminal_width);
let calculated_timeline_width = terminal_width
.saturating_sub(fixed_width_excluding_timeline + 1) .max(10);
(terminal_width, calculated_timeline_width, terminal_width)
}
fn print_trace_header(trace_id: &str, root_span_received: bool, total_table_width: usize) {
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()
);
}
fn collect_and_filter_timeline_items_for_trace(
spans_in_trace_with_service: &[(Span, String)],
attr_globs: &Option<GlobSet>,
event_severity_attribute_name: &str,
events_only: bool,
grep_regex: Option<&Regex>,
) -> Vec<TimelineItem> {
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(),
};
let span_start_item = 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),
attributes: filtered_span_attrs,
parent_span_attributes: None,
};
let mut include_item = true;
if let Some(re) = grep_regex {
include_item = false;
for attr in &span_start_item.attributes {
let value_str = get_string_value_for_grep(&attr.value);
if re.is_match(&value_str) {
include_item = true;
break;
}
}
}
if include_item {
timeline_items.push(span_start_item);
}
}
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(ProtoValue::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(),
};
let event_item = 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,
attributes: filtered_event_attrs,
parent_span_attributes: Some(filtered_parent_span_attrs),
};
let mut include_event_item = true;
if let Some(re) = grep_regex {
include_event_item = false;
for attr in &event_item.attributes {
let value_str = get_string_value_for_grep(&attr.value);
if re.is_match(&value_str) {
include_event_item = true;
break;
}
}
if !include_event_item {
if let Some(parent_attrs_for_event) = &event_item.parent_span_attributes {
for attr in parent_attrs_for_event {
let value_str = get_string_value_for_grep(&attr.value);
if re.is_match(&value_str) {
include_event_item = true;
break;
}
}
}
}
}
if include_event_item {
timeline_items.push(event_item);
}
}
}
timeline_items.sort_by_key(|item| item.timestamp_ns);
timeline_items
}
fn build_waterfall_hierarchy_and_meta(
spans_in_trace_with_service: &[(Span, String)],
) -> (Vec<ConsoleSpan>, u64, u64, HashMap<String, Span>) {
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.clone());
service_name_map.insert(span_id_hex.clone(), service_name.clone());
}
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_ns = roots.iter().map(|r| r.start_time).min().unwrap_or(0);
let max_end_time_ns = span_map .values()
.map(|s| s.end_time_unix_nano)
.max()
.unwrap_or(0);
let trace_duration_ns = max_end_time_ns.saturating_sub(min_start_time_ns);
(roots, min_start_time_ns, trace_duration_ns, span_map)
}
#[allow(clippy::too_many_arguments)]
fn render_waterfall_table(
roots: &[ConsoleSpan],
min_start_time_ns: u64,
trace_duration_ns: u64,
calculated_timeline_width: usize,
total_table_width: usize, theme: Theme,
color_by: ColoringMode,
span_map: &HashMap<String, Span>, ) -> Result<()> {
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_span in roots {
add_span_to_table(
&mut table,
root_span,
0, min_start_time_ns,
trace_duration_ns,
calculated_timeline_width,
theme,
span_map, color_by,
)?;
}
println!("{}", table);
Ok(())
}
fn print_timeline_log(
timeline_items: &[TimelineItem],
color_by: ColoringMode,
theme: Theme,
grep_regex: Option<&Regex>,
) {
if timeline_items.is_empty() {
return;
}
let mut max_service_name_len = 0;
let mut max_item_name_len = 0;
for item in timeline_items {
max_service_name_len = max_service_name_len.max(item.service_name.len());
max_item_name_len = max_item_name_len.max(item.name.len());
}
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 type_tag = match item.item_type {
ItemType::SpanStart => "SPAN".to_string(),
ItemType::Event => "EVENT".to_string(),
};
let raw_text_for_status_or_level = if item.item_type == ItemType::SpanStart {
if item.level_or_status.contains("UNSET") {
"UNSET".to_string()
} else if item.level_or_status.contains("ERROR") {
"ERROR".to_string()
} else if item.level_or_status.contains("OK") {
"OK".to_string()
} else {
"STATUS?".to_string()
}
} else {
item.level_or_status.to_uppercase()
};
let text_to_format_and_color = format!("{} {:5}", type_tag, raw_text_for_status_or_level);
let level_status_colored = match raw_text_for_status_or_level.as_str() {
"ERROR" => text_to_format_and_color.red(),
"WARN" | "WARNING" => text_to_format_and_color.yellow(),
"INFO" | "OK" => text_to_format_and_color.green(),
"UNSET" => text_to_format_and_color.dimmed(),
_ => text_to_format_and_color.dimmed(),
};
let colored_span_id_for_print = span_id_prefix
.truecolor(prefix_r, prefix_g, prefix_b)
.to_string();
let service_name_padded = format!(
"{:<width$}",
item.service_name,
width = max_service_name_len
);
let item_name_padded = format!("{:<width$}", item.name, width = max_item_name_len);
let mut attrs_to_display: Vec<String> = Vec::new();
for attr in &item.attributes {
attrs_to_display.push(format_keyvalue(attr, grep_regex));
}
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.dimmed(), value_str));
}
}
let attrs_suffix = if !attrs_to_display.is_empty() {
format!(" - {}", attrs_to_display.join(", "))
} else {
String::new()
};
println!(
"{} {:12} {} {} {} {}", formatted_time.dimmed(),
level_status_colored,
colored_span_id_for_print,
service_name_padded, item_name_padded, attrs_suffix );
}
}
#[allow(clippy::too_many_arguments)]
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, grep_regex: Option<&Regex>, ) -> Result<()> {
tracing::debug!("Display console called with theme={:?}, color_by={:?}, events_only={}, root_span_received={}, has_grep_regex={}",
theme, color_by, events_only, root_span_received, grep_regex.is_some());
let traces = prepare_trace_data_from_batch(batch)?;
if traces.is_empty() {
tracing::debug!("No traces found in batch after preparation.");
return Ok(());
}
let (_terminal_width, calculated_timeline_width, total_table_width) =
calculate_layout_widths(120);
for (trace_id, spans_in_trace_with_service) in traces {
print_trace_header(&trace_id, root_span_received, total_table_width);
if spans_in_trace_with_service.is_empty() {
continue;
}
let timeline_items = collect_and_filter_timeline_items_for_trace(
&spans_in_trace_with_service,
attr_globs,
event_severity_attribute_name,
events_only,
grep_regex,
);
let (roots, min_start_time_ns, trace_duration_ns, span_map) =
build_waterfall_hierarchy_and_meta(&spans_in_trace_with_service);
render_waterfall_table(
&roots, min_start_time_ns,
trace_duration_ns,
calculated_timeline_width,
total_table_width,
theme,
color_by,
&span_map,
)?;
if !timeline_items.is_empty() {
print_timeline_log(&timeline_items, color_by, theme, grep_regex);
}
}
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_cell_content = format!("{} {}", indent, node.name)
.chars()
.take(SPAN_NAME_WIDTH)
.collect::<String>();
let bar_cell_content = render_bar(
node.start_time,
node.duration_ns,
trace_start_time_ns,
trace_duration_ns,
timeline_width,
);
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 status_content_str = format_span_status(node.status_code);
let formatted_duration = format!("{:.2}", node.duration_ns as f64 / 1_000_000.0);
table.add_row(vec![
Cell::new(service_name_content),
Cell::new(span_name_cell_content),
Cell::new(kind_cell_content),
Cell::new(formatted_duration).set_alignment(CellAlignment::Right), Cell::new(span_id_prefix).fg(TableColor::Rgb { r, g, b }),
format_cell_level_color(&status_content_str),
Cell::new(bar_cell_content).fg(TableColor::Rgb { r, g, b }),
]);
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,
) -> 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(' ');
}
}
bar_content
}
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, grep_regex: Option<&Regex>) -> String {
let value_str = format_anyvalue(&kv.value);
if let Some(re) = grep_regex {
if re.is_match(&value_str) {
let mut highlighted_value = String::new();
let mut last_end = 0;
for mat in re.find_iter(&value_str) {
highlighted_value.push_str(&value_str[last_end..mat.start()]);
highlighted_value
.push_str(&mat.as_str().on_truecolor(255, 255, 153).black().to_string()); last_end = mat.end();
}
highlighted_value.push_str(&value_str[last_end..]);
return format!("{}: {}", kv.key.dimmed(), highlighted_value);
}
}
format!("{}: {}", kv.key.dimmed(), 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",
status::StatusCode::Error => "ERROR",
status::StatusCode::Unset => "UNSET",
}
.to_string()
}
fn format_cell_level_color(value: &str) -> Cell {
match value {
"OK" => Cell::new(value).fg(TableColor::Green),
"ERROR" => Cell::new(value).fg(TableColor::Red),
_ => Cell::new(value).fg(TableColor::DarkGrey),
}
}