Changes: - Non-blocking coding-data sharing upsell banner - Consolidate remediation in Doctor - Auto mode defers fail-closed gate asks to the classifier - Coalesce marketplace list fetches - Allow removing a marketplace source by name - Contain hung git marketplace sources (timeouts, non-blocking refresh, unbrick modal) - Label failed workspace RPCs with error_kind - Drop redundant explicit tonic/prost deps from xai-grok-shell - Report real exit codes for completed background shells - Narrow the date-rollover reminder to date-bearing templates - Wire toolOverrides through the session and agent - Security: Bash(git:*) allowlist matches whole command chain by prefix - Split prompt-trigger telemetry and record classifier provenance - Raise connectors-manager timeout to 60s - Auto classifier honors recorded approvals for repeat actions - Apply doctor fixes in the TUI - Auto-mode classifier timeouts prompt instead of silently denying - Scope subagent completion drains to the owning session - Add the toolOverrides wire types - Set client_identifier=grok-agent-sdk - Accept both spellings of the workspace-teleport kill switch - Persist one-shot occurrence journal - Stop turns that poll the exact same tool call 16x in a row - Copy compaction checkpoint files when forking sessions - Auto-focus permission prompt from scrollback - Esc cancels the running turn in non-vim and minimal modes - List Ctrl+Z undo and redo in keyboard shortcuts - Out-of-process macOS mic capture - Show active auth mode on session-info - Install the npm binary under $GROK_HOME - Remove hover/click dead zones between dashboard items - Route startup warnings to doctor - Document [feedback.user] author identity config - Extend bang command timeout - Close combine-queued edit-hold race - Integrate relocation recovery - Expose privacy notice rollout flag - Break harness discovery ref cycle so connections can idle-evict - Shift/Alt+Enter inserts newline when editing a queued prompt - Gate project Claude permissions on folder trust - Echo response.create.event_id on response.created - Toast when session creation fails from disk full - Add shared test process lifecycle - Enable dynamic workflows by default - Add relocation transaction state machine - Add shared test sandbox - Surface auth failures on model-switch compact - Persist durable scheduler expiry - Confirm before removing extensions-modal items - Re-run compact and prompt after login when compact hit expired auth - Recap sends hosted tools under backend search
915 lines
34 KiB
Rust
915 lines
34 KiB
Rust
//! Tracing capture and display for the pager's tracing pane.
|
|
//!
|
|
//! This module provides:
|
|
//!
|
|
//! - [`TracingEntry`] — a single log line, parsed from an ANSI-formatted string
|
|
//! into a ratatui [`Text`] for rendering and a plain-text [`Arc<str>`] for search.
|
|
//! Implements [`ListItem`] so it can be displayed in a [`ListPane`].
|
|
//!
|
|
//! - [`TracingModel`] — a bounded, append-only ring buffer of [`TracingEntry`] items.
|
|
//! Uses `Vec` with batch eviction (not `VecDeque`) so that `as_slice()` returns a
|
|
//! single contiguous `&[TracingEntry]` for the `ListPane` API.
|
|
//!
|
|
//! ## Architecture (Option A — ANSI pass-through)
|
|
//!
|
|
//! The current approach receives pre-formatted ANSI strings from
|
|
//! `tracing-subscriber`'s `Full` formatter, parses them with `ansi-to-tui` into
|
|
//! styled ratatui `Text`, and renders them directly. This is the simplest path
|
|
//! to a working tracing pane.
|
|
//!
|
|
//! ## Future: Option B — Structured capture
|
|
//!
|
|
//! A future iteration could replace the ANSI pass-through with a custom
|
|
//! `tracing_subscriber::Layer` that captures structured event data (level, target,
|
|
//! spans, fields) into `TracingEntry` directly. The `ListItem` trait boundary
|
|
//! insulates `ListPane` from this change — only this module would need updating.
|
|
//! The `TracingEntry::new()` constructor is the seam: swap its internals from
|
|
//! "parse ANSI string" to "format structured fields" and nothing else changes.
|
|
use crate::views::list_pane::ListItem;
|
|
use ansi_to_tui::IntoText;
|
|
use ratatui::text::{Line, Text};
|
|
use std::io;
|
|
use std::sync::Arc;
|
|
use tokio::sync::mpsc;
|
|
use tracing_subscriber::fmt::MakeWriter;
|
|
/// A single tracing log entry, ready for display in a `ListPane`.
|
|
///
|
|
/// Created from a pre-formatted ANSI string (as produced by
|
|
/// `tracing_subscriber::fmt` with `with_ansi(true)`). The ANSI is parsed once
|
|
/// at construction time into:
|
|
/// - `styled` — ratatui `Text<'static>` with color/style spans, for rendering
|
|
/// - `plain` — ANSI-stripped plain text, for search/filter matching
|
|
///
|
|
/// Both are immutable after construction.
|
|
#[derive(Debug, Clone)]
|
|
pub struct TracingEntry {
|
|
/// Monotonic sequence number. Used as `stable_id()` for `ListItem`.
|
|
seq: u64,
|
|
/// Raw ANSI string, kept for re-styling on theme switch.
|
|
raw_ansi: Arc<str>,
|
|
/// ANSI-stripped plain text for `search_text()`.
|
|
plain: Arc<str>,
|
|
/// Pre-parsed styled text for rendering. Produced by `ansi-to-tui`.
|
|
styled: Text<'static>,
|
|
}
|
|
impl TracingEntry {
|
|
/// Create a new entry from a pre-formatted ANSI string.
|
|
///
|
|
/// Parses the ANSI escape sequences into ratatui styled spans and extracts
|
|
/// plain text for search. The `seq` is a monotonic ID assigned by the
|
|
/// [`TracingModel`].
|
|
///
|
|
/// If ANSI parsing fails (malformed escapes), falls back to plain unstyled
|
|
/// text — we never drop a log line.
|
|
pub fn new(seq: u64, ansi: &str) -> Self {
|
|
let raw_ansi: Arc<str> = Arc::from(ansi);
|
|
let styled = Self::parse_and_style(ansi);
|
|
let plain = plain_text_from_styled(&styled);
|
|
Self {
|
|
seq,
|
|
raw_ansi,
|
|
plain,
|
|
styled,
|
|
}
|
|
}
|
|
/// Re-apply theme colors to this entry (called on theme switch).
|
|
pub fn restyle(&mut self) {
|
|
self.styled = Self::parse_and_style(&self.raw_ansi);
|
|
}
|
|
/// Parse ANSI and map basic ANSI colors to the current theme palette.
|
|
fn parse_and_style(ansi: &str) -> Text<'static> {
|
|
let mut styled = ansi
|
|
.as_bytes()
|
|
.into_text()
|
|
.unwrap_or_else(|_| Text::raw(ansi.to_owned()));
|
|
let theme = crate::theme::Theme::current();
|
|
for line in &mut styled.lines {
|
|
for span in &mut line.spans {
|
|
use ratatui::style::Color;
|
|
span.style.fg = Some(match span.style.fg {
|
|
Some(Color::Green) => theme.accent_success,
|
|
Some(Color::Yellow) => theme.warning,
|
|
Some(Color::Red) => theme.accent_error,
|
|
Some(Color::Blue) => theme.accent_system,
|
|
Some(Color::Magenta) => theme.accent_assistant,
|
|
Some(Color::Cyan) => theme.running,
|
|
None | Some(Color::Reset) | Some(Color::White) => theme.text_primary,
|
|
_ => theme.gray,
|
|
});
|
|
span.style.bg = None;
|
|
}
|
|
}
|
|
styled
|
|
}
|
|
/// The monotonic sequence number.
|
|
pub fn seq(&self) -> u64 {
|
|
self.seq
|
|
}
|
|
/// The ANSI-stripped plain text.
|
|
pub fn plain(&self) -> &str {
|
|
&self.plain
|
|
}
|
|
/// The pre-parsed styled text.
|
|
pub fn styled(&self) -> &Text<'static> {
|
|
&self.styled
|
|
}
|
|
}
|
|
/// Extract plain text from a ratatui `Text` by concatenating all span contents.
|
|
///
|
|
/// Lines are joined with `\n` (matching how `search_text()` should work for
|
|
/// multi-line entries, though tracing entries are typically single-line).
|
|
fn plain_text_from_styled(text: &Text<'_>) -> Arc<str> {
|
|
if text.lines.len() == 1 && text.lines[0].spans.len() == 1 {
|
|
return Arc::from(text.lines[0].spans[0].content.as_ref());
|
|
}
|
|
let mut buf = String::new();
|
|
for (i, line) in text.lines.iter().enumerate() {
|
|
if i > 0 {
|
|
buf.push('\n');
|
|
}
|
|
for span in &line.spans {
|
|
buf.push_str(&span.content);
|
|
}
|
|
}
|
|
Arc::from(buf)
|
|
}
|
|
impl ListItem for TracingEntry {
|
|
fn content(&self) -> &Line<'_> {
|
|
self.styled
|
|
.lines
|
|
.first()
|
|
.expect("TracingEntry should have at least one line")
|
|
}
|
|
fn stable_id(&self) -> u64 {
|
|
self.seq
|
|
}
|
|
fn search_text(&self) -> &str {
|
|
&self.plain
|
|
}
|
|
}
|
|
/// Bounded, append-only buffer of [`TracingEntry`] items.
|
|
///
|
|
/// Uses `Vec` (not `VecDeque`) so that [`as_slice()`](Self::as_slice) returns a
|
|
/// single contiguous `&[TracingEntry]` — required by `ListPane`'s API.
|
|
///
|
|
/// Eviction strategy: when `len > capacity + hysteresis`, drain the oldest
|
|
/// `hysteresis` entries in one batch. This amortizes the memcpy cost. At
|
|
/// 100 msgs/sec with hysteresis=5000, eviction happens roughly every 50
|
|
/// seconds — negligible.
|
|
#[derive(Debug)]
|
|
pub struct TracingModel {
|
|
entries: Vec<TracingEntry>,
|
|
/// Maximum number of entries to retain after eviction.
|
|
capacity: usize,
|
|
/// Entries allowed beyond `capacity` before triggering eviction.
|
|
hysteresis: usize,
|
|
/// Next sequence number to assign.
|
|
next_seq: u64,
|
|
}
|
|
/// Result of a [`TracingModel::push`] operation.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct PushResult {
|
|
/// Number of entries evicted from the front (0 most of the time).
|
|
pub evicted: usize,
|
|
}
|
|
impl TracingModel {
|
|
/// Create a new model with the given capacity and hysteresis.
|
|
///
|
|
/// - `capacity`: target number of entries to retain after eviction.
|
|
/// - `hysteresis`: how many entries beyond `capacity` before eviction fires.
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Panics if `capacity == 0`.
|
|
pub fn new(capacity: usize, hysteresis: usize) -> Self {
|
|
assert!(capacity > 0, "TracingModel capacity must be > 0");
|
|
Self {
|
|
entries: Vec::with_capacity(capacity + hysteresis),
|
|
capacity,
|
|
hysteresis,
|
|
next_seq: 0,
|
|
}
|
|
}
|
|
/// Append a log line (pre-formatted ANSI string).
|
|
///
|
|
/// Returns the number of entries evicted from the front (0 unless the
|
|
/// buffer exceeded `capacity + hysteresis`).
|
|
pub fn push(&mut self, ansi: &str) -> PushResult {
|
|
let entry = TracingEntry::new(self.next_seq, ansi);
|
|
self.next_seq += 1;
|
|
self.entries.push(entry);
|
|
let evicted = self.maybe_evict();
|
|
PushResult { evicted }
|
|
}
|
|
/// Append a pre-constructed [`TracingEntry`].
|
|
///
|
|
/// Useful if the caller wants to construct entries with a custom formatter
|
|
/// (Option B) instead of ANSI pass-through.
|
|
pub fn push_entry(&mut self, mut entry: TracingEntry) -> PushResult {
|
|
entry.seq = self.next_seq;
|
|
self.next_seq += 1;
|
|
self.entries.push(entry);
|
|
let evicted = self.maybe_evict();
|
|
PushResult { evicted }
|
|
}
|
|
/// Evict oldest entries if we've exceeded the threshold.
|
|
fn maybe_evict(&mut self) -> usize {
|
|
let threshold = self.capacity + self.hysteresis;
|
|
if self.entries.len() > threshold {
|
|
let keep_from = self.entries.len() - self.capacity;
|
|
self.entries.drain(..keep_from);
|
|
keep_from
|
|
} else {
|
|
0
|
|
}
|
|
}
|
|
/// Contiguous slice of all current entries.
|
|
///
|
|
/// This is the slice you pass to `ListPaneState::prepare_layout()` and
|
|
/// `ListPane::new()`.
|
|
pub fn as_slice(&self) -> &[TracingEntry] {
|
|
&self.entries
|
|
}
|
|
/// Number of entries currently stored.
|
|
pub fn len(&self) -> usize {
|
|
self.entries.len()
|
|
}
|
|
/// Whether the buffer is empty.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.entries.is_empty()
|
|
}
|
|
/// The configured capacity (max entries after eviction).
|
|
pub fn capacity(&self) -> usize {
|
|
self.capacity
|
|
}
|
|
/// The configured hysteresis (slack before eviction triggers).
|
|
pub fn hysteresis(&self) -> usize {
|
|
self.hysteresis
|
|
}
|
|
/// The next sequence number that will be assigned.
|
|
pub fn next_seq(&self) -> u64 {
|
|
self.next_seq
|
|
}
|
|
/// Clear all entries and reset the sequence counter.
|
|
pub fn clear(&mut self) {
|
|
self.entries.clear();
|
|
self.next_seq = 0;
|
|
}
|
|
/// The sequence number of the first (oldest) entry, if any.
|
|
pub fn first_seq(&self) -> Option<u64> {
|
|
self.entries.first().map(|e| e.seq)
|
|
}
|
|
/// The sequence number of the last (newest) entry, if any.
|
|
pub fn last_seq(&self) -> Option<u64> {
|
|
self.entries.last().map(|e| e.seq)
|
|
}
|
|
/// Re-apply theme colors to all entries (called on theme switch).
|
|
pub fn restyle_all(&mut self) {
|
|
for entry in &mut self.entries {
|
|
entry.restyle();
|
|
}
|
|
}
|
|
}
|
|
/// Target for the full ACP update payload dump (plain JSON, no ANSI).
|
|
///
|
|
/// Off by default in release builds: serializing every update at streaming
|
|
/// rate (bash `raw_output` byte arrays reach hundreds of KB per line) plus
|
|
/// retention in the log channel was the 50-60GB OOM class. Payload fields on
|
|
/// this target must be wrapped in [`LazyJson`] so serialization only happens
|
|
/// inside a recording subscriber: the dev pane filter (dev builds) or the
|
|
/// firehose (`GROK_DEBUG_LOG` / `GROK_LOG_FILE`).
|
|
pub use xai_grok_telemetry::debug_log::ACP_UPDATE_PAYLOAD_TARGET;
|
|
/// Target for the always-on compact ACP update summary line (kind, ids,
|
|
/// status, payload sizes). Cheap to format at streaming rate.
|
|
///
|
|
/// Defined in `xai-grok-telemetry` so the firehose directives and the pager
|
|
/// filter share one constant (re-exported here for callsites).
|
|
pub use xai_grok_telemetry::debug_log::ACP_UPDATE_TARGET;
|
|
/// Lazily JSON-serializes a value inside `Display::fmt`.
|
|
///
|
|
/// Use as a `%`-captured event field so `serde_json::to_string` runs only
|
|
/// when a layer whose filter passed actually records the field. A bare
|
|
/// `serde_json::to_string(..)` macro argument is NOT lazy: the registry
|
|
/// includes filterless layers (e.g. the disabled-telemetry `NoOpLayer`s)
|
|
/// whose default `register_callsite` reports `Interest::always()`, globally
|
|
/// enabling the callsite — per-layer filters only gate recording, not
|
|
/// argument evaluation.
|
|
pub struct LazyJson<'a, T>(pub &'a T);
|
|
impl<T: serde::Serialize> std::fmt::Display for LazyJson<'_, T> {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(&serde_json::to_string(self.0).unwrap_or_default())
|
|
}
|
|
}
|
|
/// Capacity of the log channel between tracing-subscriber and the UI.
|
|
///
|
|
/// Bounded so a starved consumer (the event loop drains it only on ticks,
|
|
/// which are deprioritized below ACP traffic) caps retention at
|
|
/// `capacity x line size` instead of growing without limit. On overflow the
|
|
/// newest line is dropped and [`dropped_log_lines`] is incremented.
|
|
const LOG_CHANNEL_CAPACITY: usize = 16 * 1024;
|
|
/// Lines dropped due to a full log channel (process-wide).
|
|
static DROPPED_LOG_LINES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
/// Total log lines dropped so far because the channel was full.
|
|
pub fn dropped_log_lines() -> u64 {
|
|
DROPPED_LOG_LINES.load(std::sync::atomic::Ordering::Relaxed)
|
|
}
|
|
/// Type aliases for the channel endpoints.
|
|
pub type LogTx = mpsc::Sender<String>;
|
|
pub type LogRx = mpsc::Receiver<String>;
|
|
/// Factory that creates [`TracingChannelWriter`] instances for `tracing-subscriber`.
|
|
///
|
|
/// Implements [`MakeWriter`] so it can be passed to
|
|
/// `tracing_subscriber::fmt().with_writer(make_writer)`.
|
|
///
|
|
/// Created via [`TracingChannelMakeWriter::new()`], which returns the writer
|
|
/// factory and the receiving end of the channel.
|
|
#[derive(Clone)]
|
|
pub struct TracingChannelMakeWriter(LogTx);
|
|
impl TracingChannelMakeWriter {
|
|
/// Create a new channel writer pair.
|
|
///
|
|
/// Returns `(make_writer, receiver)`. Pass `make_writer` to
|
|
/// `tracing_subscriber::fmt().with_writer(...)`. Poll `receiver` in your
|
|
/// event loop and feed each `String` to [`TracingModel::push()`].
|
|
pub fn new() -> (Self, LogRx) {
|
|
let (tx, rx) = mpsc::channel(LOG_CHANNEL_CAPACITY);
|
|
(Self(tx), rx)
|
|
}
|
|
}
|
|
impl<'a> MakeWriter<'a> for TracingChannelMakeWriter {
|
|
type Writer = TracingChannelWriter;
|
|
fn make_writer(&'a self) -> Self::Writer {
|
|
TracingChannelWriter { tx: self.0.clone() }
|
|
}
|
|
}
|
|
/// Writer that sends each formatted log line to a bounded mpsc channel
|
|
/// (drop-on-full; see [`write()`](io::Write::write) — logging must never OOM
|
|
/// or back-pressure the runtime, so a full channel drops the line).
|
|
///
|
|
/// Created by [`TracingChannelMakeWriter`]. Each call to [`write()`](io::Write::write)
|
|
/// trims ASCII whitespace, skips empty lines, and sends the result as a `String`.
|
|
///
|
|
/// The receiver side (held by the event loop) drains these strings into a
|
|
/// [`TracingModel`].
|
|
#[derive(Clone)]
|
|
pub struct TracingChannelWriter {
|
|
tx: LogTx,
|
|
}
|
|
impl io::Write for TracingChannelWriter {
|
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
|
let s = String::from_utf8_lossy(buf.trim_ascii());
|
|
if !s.is_empty() {
|
|
match self.tx.try_send(s.into_owned()) {
|
|
Ok(()) => {}
|
|
Err(mpsc::error::TrySendError::Full(_)) => {
|
|
DROPPED_LOG_LINES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
}
|
|
Err(mpsc::error::TrySendError::Closed(_)) => {
|
|
return Err(io::Error::other("tracing channel send failed"));
|
|
}
|
|
}
|
|
}
|
|
Ok(buf.len())
|
|
}
|
|
fn flush(&mut self) -> io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
/// Return value from [`init_tracing()`].
|
|
///
|
|
/// Holds the receiving end of the log channel. The caller should poll `rx` in
|
|
/// the event loop and feed each `String` to [`TracingModel::push()`].
|
|
pub struct TracingHandle {
|
|
/// Receive log lines here. Each string is a pre-formatted ANSI line from
|
|
/// `tracing-subscriber`'s `Full` formatter.
|
|
pub rx: LogRx,
|
|
}
|
|
/// Initialize a `tracing-subscriber` that captures formatted log lines into a
|
|
/// channel, ready for display in a [`TracingModel`].
|
|
///
|
|
/// This sets the global default subscriber. Call it once at startup, before any
|
|
/// `tracing::info!()` calls.
|
|
///
|
|
/// The subscriber uses:
|
|
/// - `Full` formatter (timestamp + level + target + message)
|
|
/// - ANSI colors enabled (`with_ansi(true)`)
|
|
/// - `RUST_LOG` env filter (defaults to `info` if unset)
|
|
///
|
|
/// Returns a [`TracingHandle`] whose `rx` field should be polled each tick.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```ignore
|
|
/// let handle = init_tracing();
|
|
/// // In event loop:
|
|
/// while let Ok(line) = handle.rx.try_recv() {
|
|
/// model.push(&line);
|
|
/// }
|
|
/// ```
|
|
pub fn init_tracing() -> TracingHandle {
|
|
use tracing_subscriber::{
|
|
EnvFilter, Layer as _, filter::LevelFilter, fmt, layer::SubscriberExt as _,
|
|
};
|
|
use xai_grok_telemetry::debug_log::RMCP_SSE_NOISE_TARGET;
|
|
let (make_writer, rx) = TracingChannelMakeWriter::new();
|
|
let payload_level = "off";
|
|
let directives = format!(
|
|
"xai_grok_shell=info,xai_grok_pager=trace,xai_grok_tools=info,xai_acp_lib=info,{RMCP_SSE_NOISE_TARGET}=error,sampling_log=off,{ACP_UPDATE_TARGET}=debug,{ACP_UPDATE_PAYLOAD_TARGET}={payload_level}"
|
|
);
|
|
let env_filter = EnvFilter::builder()
|
|
.with_default_directive(LevelFilter::WARN.into())
|
|
.parse_lossy(&directives);
|
|
let fmt_layer = fmt::layer()
|
|
.with_target(true)
|
|
.with_ansi(true)
|
|
.with_writer(make_writer);
|
|
let otel_layer = xai_grok_telemetry::otel_layer::build_otel_layer(
|
|
xai_grok_telemetry::otel_layer::OtelClientInfo {
|
|
client_name: "grok-pager",
|
|
client_version: xai_grok_version::VERSION,
|
|
service_version: env!("VERSION_WITH_COMMIT"),
|
|
app_entrypoint: "tui",
|
|
},
|
|
xai_grok_shell::auth::credential_provider::build_default_otel_layer_config(),
|
|
);
|
|
let instrumentation_layer = xai_grok_telemetry::instrumentation::layer();
|
|
let sampling_log_layer = xai_grok_telemetry::sampling_log::layer();
|
|
let hooks_log_layer = xai_grok_telemetry::hooks_log::layer();
|
|
let registry = tracing_subscriber::registry()
|
|
.with(fmt_layer.with_filter(env_filter))
|
|
.with(instrumentation_layer)
|
|
.with(sampling_log_layer)
|
|
.with(hooks_log_layer)
|
|
.with(otel_layer);
|
|
xai_grok_telemetry::debug_log::install_firehose(registry, "tui");
|
|
xai_grok_telemetry::external::init(
|
|
xai_grok_shell::agent::config::resolve_external_otel_config(
|
|
xai_grok_telemetry::external::config::ExternalClientInfo {
|
|
service_version: env!("VERSION_WITH_COMMIT").to_owned(),
|
|
client_version: xai_grok_version::VERSION.to_owned(),
|
|
app_entrypoint: "tui".to_owned(),
|
|
},
|
|
),
|
|
);
|
|
TracingHandle { rx }
|
|
}
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use ratatui::style::Modifier;
|
|
/// Records whether `Serialize` ever ran.
|
|
struct SerializeProbe(std::sync::Arc<std::sync::atomic::AtomicBool>);
|
|
impl serde::Serialize for SerializeProbe {
|
|
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
|
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
s.serialize_str("probe-payload")
|
|
}
|
|
}
|
|
/// Filterless layer with all-default methods, mirroring the disabled
|
|
/// telemetry `NoOpLayer`s in the production registry: its default
|
|
/// `register_callsite` reports `Interest::always()`, keeping every
|
|
/// callsite globally enabled. This is the condition that defeats a bare
|
|
/// (non-lazy) macro argument.
|
|
struct FilterlessNoOp;
|
|
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for FilterlessNoOp {}
|
|
/// Emit the production-shaped payload event against a registry with the
|
|
/// given payload-target directive; return (serialized?, line received?).
|
|
fn run_payload_event(directive: &str) -> (bool, bool) {
|
|
use tracing_subscriber::layer::SubscriberExt as _;
|
|
use tracing_subscriber::{EnvFilter, Layer as _};
|
|
let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let probe = SerializeProbe(flag.clone());
|
|
let (make_writer, mut rx) = TracingChannelMakeWriter::new();
|
|
let fmt_layer = tracing_subscriber::fmt::layer()
|
|
.with_ansi(false)
|
|
.with_writer(make_writer)
|
|
.with_filter(EnvFilter::new(directive));
|
|
let subscriber = tracing_subscriber::registry()
|
|
.with(fmt_layer)
|
|
.with(FilterlessNoOp);
|
|
tracing::subscriber::with_default(subscriber, || {
|
|
tracing::debug!(
|
|
target: "acp_update_payload",
|
|
payload = %LazyJson(&probe),
|
|
"[acp]",
|
|
);
|
|
});
|
|
let serialized = flag.load(std::sync::atomic::Ordering::SeqCst);
|
|
let line = rx.try_recv().ok();
|
|
(serialized, line.is_some())
|
|
}
|
|
#[test]
|
|
fn lazy_json_not_serialized_when_payload_target_off() {
|
|
let (serialized, line_received) = run_payload_event("acp_update_payload=off");
|
|
assert!(!serialized, "payload serialized despite target=off");
|
|
assert!(!line_received, "no log line should be emitted");
|
|
}
|
|
#[test]
|
|
fn lazy_json_serialized_when_payload_target_on() {
|
|
let (serialized, line_received) = run_payload_event("acp_update_payload=debug");
|
|
assert!(serialized, "payload should serialize when target enabled");
|
|
assert!(line_received, "log line should be emitted");
|
|
}
|
|
#[test]
|
|
fn lazy_json_display_renders_json() {
|
|
assert_eq!(
|
|
format!("{}", LazyJson(&serde_json::json!({"a": 1}))),
|
|
r#"{"a":1}"#
|
|
);
|
|
}
|
|
#[test]
|
|
fn entry_from_plain_text() {
|
|
let entry = TracingEntry::new(42, "hello world");
|
|
assert_eq!(entry.seq(), 42);
|
|
assert_eq!(entry.plain(), "hello world");
|
|
assert_eq!(entry.styled().lines.len(), 1);
|
|
assert_eq!(entry.styled().lines[0].spans.len(), 1);
|
|
assert_eq!(
|
|
entry.styled().lines[0].spans[0].content.as_ref(),
|
|
"hello world"
|
|
);
|
|
}
|
|
#[test]
|
|
fn entry_from_ansi_bold() {
|
|
let entry = TracingEntry::new(0, "\x1b[1mBOLD\x1b[0m normal");
|
|
assert_eq!(entry.plain(), "BOLD normal");
|
|
let spans = &entry.styled().lines[0].spans;
|
|
assert!(spans.len() >= 2, "expected ≥2 spans, got {}", spans.len());
|
|
assert!(
|
|
spans[0].style.add_modifier.contains(Modifier::BOLD),
|
|
"first span should be bold: {:?}",
|
|
spans[0].style
|
|
);
|
|
assert_eq!(spans[0].content.as_ref(), "BOLD");
|
|
}
|
|
#[test]
|
|
#[ignore = "known broken: ANSI color expectations no longer match parsed RGB output"]
|
|
fn entry_from_ansi_colored() {
|
|
let entry = TracingEntry::new(1, "\x1b[32mINFO\x1b[0m some message");
|
|
assert_eq!(entry.plain(), "INFO some message");
|
|
let first_span = &entry.styled().lines[0].spans[0];
|
|
assert_eq!(first_span.content.as_ref(), "INFO");
|
|
let theme = crate::theme::Theme::current();
|
|
assert_eq!(
|
|
first_span.style.fg,
|
|
Some(theme.accent_success),
|
|
"expected theme accent_success, got {:?}",
|
|
first_span.style.fg
|
|
);
|
|
}
|
|
#[test]
|
|
fn entry_from_realistic_tracing_line() {
|
|
let line = "\x1b[2m2025-02-16T10:30:00.123Z\x1b[0m \x1b[32m INFO\x1b[0m \x1b[2mmy_crate::module\x1b[0m\x1b[2m:\x1b[0m hello from tracing";
|
|
let entry = TracingEntry::new(99, line);
|
|
assert_eq!(
|
|
entry.plain(),
|
|
"2025-02-16T10:30:00.123Z INFO my_crate::module: hello from tracing"
|
|
);
|
|
assert!(entry.styled().lines[0].spans.len() >= 4);
|
|
}
|
|
#[test]
|
|
fn entry_malformed_ansi_does_not_panic() {
|
|
let cases = [
|
|
"\x1b[999mhello\x1b[0m",
|
|
"\x1b[",
|
|
"\x1b[1",
|
|
"before \x1b[999 after",
|
|
"\x1b",
|
|
"\x1b[32;1;4;999mstuff",
|
|
];
|
|
for input in &cases {
|
|
let entry = TracingEntry::new(0, input);
|
|
let _ = entry.plain();
|
|
let _ = entry.styled();
|
|
let _ = entry.search_text();
|
|
}
|
|
}
|
|
#[test]
|
|
fn entry_empty_string() {
|
|
let entry = TracingEntry::new(0, "");
|
|
assert_eq!(entry.plain(), "");
|
|
assert_eq!(entry.stable_id(), 0);
|
|
}
|
|
#[test]
|
|
fn entry_multiline_ansi() {
|
|
let entry = TracingEntry::new(0, "line1\nline2\nline3");
|
|
assert_eq!(entry.plain(), "line1\nline2\nline3");
|
|
assert_eq!(entry.styled().lines.len(), 3);
|
|
assert_eq!(entry.desired_height(80), 1);
|
|
}
|
|
#[test]
|
|
fn list_item_stable_id() {
|
|
let entry = TracingEntry::new(12345, "test");
|
|
assert_eq!(entry.stable_id(), 12345);
|
|
}
|
|
#[test]
|
|
fn list_item_search_text() {
|
|
let entry = TracingEntry::new(0, "\x1b[32mINFO\x1b[0m hello");
|
|
assert_eq!(entry.search_text(), "INFO hello");
|
|
}
|
|
#[test]
|
|
fn list_item_desired_height_single_line() {
|
|
let entry = TracingEntry::new(0, "single line");
|
|
assert_eq!(entry.desired_height(80), 1);
|
|
}
|
|
#[test]
|
|
fn list_item_content_returns_styled_line() {
|
|
let entry = TracingEntry::new(0, "\x1b[32mINFO\x1b[0m hello");
|
|
let content = entry.content();
|
|
let text: String = content.spans.iter().map(|s| s.content.as_ref()).collect();
|
|
assert_eq!(text, "INFO hello");
|
|
}
|
|
#[test]
|
|
#[ignore = "known broken: ANSI color expectations no longer match parsed RGB output"]
|
|
fn list_item_content_preserves_color() {
|
|
let entry = TracingEntry::new(0, "\x1b[32mGREEN\x1b[0m");
|
|
let content = entry.content();
|
|
assert!(!content.spans.is_empty());
|
|
let first = &content.spans[0];
|
|
assert_eq!(first.content.as_ref(), "GREEN");
|
|
let theme = crate::theme::Theme::current();
|
|
assert_eq!(
|
|
first.style.fg,
|
|
Some(theme.accent_success),
|
|
"expected theme accent_success, got {:?}",
|
|
first.style.fg
|
|
);
|
|
}
|
|
#[test]
|
|
fn list_item_desired_height_wide_content() {
|
|
let entry = TracingEntry::new(0, "abcdefghij");
|
|
assert_eq!(entry.desired_height(4), 3);
|
|
}
|
|
#[test]
|
|
fn list_item_desired_height_fits() {
|
|
let entry = TracingEntry::new(0, "short");
|
|
assert_eq!(entry.desired_height(80), 1);
|
|
}
|
|
#[test]
|
|
fn model_push_and_len() {
|
|
let mut model = TracingModel::new(100, 10);
|
|
assert!(model.is_empty());
|
|
assert_eq!(model.len(), 0);
|
|
model.push("line 1");
|
|
model.push("line 2");
|
|
assert_eq!(model.len(), 2);
|
|
assert!(!model.is_empty());
|
|
}
|
|
#[test]
|
|
fn model_as_slice() {
|
|
let mut model = TracingModel::new(100, 10);
|
|
model.push("aaa");
|
|
model.push("bbb");
|
|
model.push("ccc");
|
|
let slice = model.as_slice();
|
|
assert_eq!(slice.len(), 3);
|
|
assert_eq!(slice[0].plain(), "aaa");
|
|
assert_eq!(slice[1].plain(), "bbb");
|
|
assert_eq!(slice[2].plain(), "ccc");
|
|
}
|
|
#[test]
|
|
fn model_seq_numbers_monotonic() {
|
|
let mut model = TracingModel::new(100, 10);
|
|
model.push("a");
|
|
model.push("b");
|
|
model.push("c");
|
|
let seqs: Vec<u64> = model.as_slice().iter().map(|e| e.seq()).collect();
|
|
assert_eq!(seqs, vec![0, 1, 2]);
|
|
assert_eq!(model.next_seq(), 3);
|
|
}
|
|
#[test]
|
|
fn model_eviction_triggers_at_threshold() {
|
|
let mut model = TracingModel::new(5, 3);
|
|
for i in 0..8 {
|
|
let result = model.push(&format!("line {i}"));
|
|
assert_eq!(result.evicted, 0, "should not evict at len={}", i + 1);
|
|
}
|
|
assert_eq!(model.len(), 8);
|
|
let result = model.push("line 8");
|
|
assert_eq!(result.evicted, 4);
|
|
assert_eq!(model.len(), 5);
|
|
let plains: Vec<&str> = model.as_slice().iter().map(|e| e.plain()).collect();
|
|
assert_eq!(
|
|
plains,
|
|
vec!["line 4", "line 5", "line 6", "line 7", "line 8"]
|
|
);
|
|
}
|
|
#[test]
|
|
fn model_eviction_preserves_seq_numbers() {
|
|
let mut model = TracingModel::new(3, 2);
|
|
for i in 0..6 {
|
|
model.push(&format!("line {i}"));
|
|
}
|
|
assert_eq!(model.len(), 3);
|
|
assert_eq!(model.first_seq(), Some(3));
|
|
assert_eq!(model.last_seq(), Some(5));
|
|
assert_eq!(model.next_seq(), 6);
|
|
}
|
|
#[test]
|
|
fn model_clear() {
|
|
let mut model = TracingModel::new(100, 10);
|
|
model.push("a");
|
|
model.push("b");
|
|
assert_eq!(model.len(), 2);
|
|
model.clear();
|
|
assert!(model.is_empty());
|
|
assert_eq!(model.next_seq(), 0);
|
|
}
|
|
#[test]
|
|
fn model_push_entry_custom() {
|
|
let mut model = TracingModel::new(100, 10);
|
|
let entry = TracingEntry::new(999, "custom");
|
|
let result = model.push_entry(entry);
|
|
assert_eq!(result.evicted, 0);
|
|
assert_eq!(model.as_slice()[0].seq(), 0);
|
|
assert_eq!(model.as_slice()[0].plain(), "custom");
|
|
}
|
|
#[test]
|
|
fn model_first_last_seq_empty() {
|
|
let model = TracingModel::new(100, 10);
|
|
assert_eq!(model.first_seq(), None);
|
|
assert_eq!(model.last_seq(), None);
|
|
}
|
|
#[test]
|
|
#[should_panic(expected = "capacity must be > 0")]
|
|
fn model_zero_capacity_panics() {
|
|
TracingModel::new(0, 10);
|
|
}
|
|
#[test]
|
|
fn model_zero_hysteresis() {
|
|
let mut model = TracingModel::new(3, 0);
|
|
model.push("a");
|
|
model.push("b");
|
|
model.push("c");
|
|
assert_eq!(model.len(), 3);
|
|
let result = model.push("d");
|
|
assert_eq!(result.evicted, 1);
|
|
assert_eq!(model.len(), 3);
|
|
assert_eq!(model.as_slice()[0].plain(), "b");
|
|
}
|
|
#[test]
|
|
fn model_large_batch_eviction() {
|
|
let mut model = TracingModel::new(10, 5);
|
|
for i in 0..15 {
|
|
model.push(&format!("{i}"));
|
|
}
|
|
assert_eq!(model.len(), 15);
|
|
let result = model.push("15");
|
|
assert_eq!(result.evicted, 6);
|
|
assert_eq!(model.len(), 10);
|
|
assert_eq!(model.first_seq(), Some(6));
|
|
}
|
|
#[test]
|
|
fn model_entries_usable_as_list_items() {
|
|
let mut model = TracingModel::new(100, 10);
|
|
model.push("\x1b[32m INFO\x1b[0m hello");
|
|
model.push("\x1b[31mERROR\x1b[0m world");
|
|
let slice = model.as_slice();
|
|
assert_eq!(slice[0].stable_id(), 0);
|
|
assert_eq!(slice[1].stable_id(), 1);
|
|
assert_eq!(slice[0].search_text(), " INFO hello");
|
|
assert_eq!(slice[1].search_text(), "ERROR world");
|
|
assert_eq!(slice[0].desired_height(80), 1);
|
|
}
|
|
#[test]
|
|
fn plain_text_fast_path() {
|
|
let text = Text::raw("simple");
|
|
let plain = plain_text_from_styled(&text);
|
|
assert_eq!(&*plain, "simple");
|
|
}
|
|
#[test]
|
|
fn plain_text_multi_span() {
|
|
use ratatui::text::{Line, Span};
|
|
let text = Text::from(Line::from(vec![
|
|
Span::raw("hello"),
|
|
Span::raw(" "),
|
|
Span::raw("world"),
|
|
]));
|
|
let plain = plain_text_from_styled(&text);
|
|
assert_eq!(&*plain, "hello world");
|
|
}
|
|
#[test]
|
|
fn plain_text_multi_line() {
|
|
let text = Text::from(vec![
|
|
ratatui::text::Line::raw("line1"),
|
|
ratatui::text::Line::raw("line2"),
|
|
]);
|
|
let plain = plain_text_from_styled(&text);
|
|
assert_eq!(&*plain, "line1\nline2");
|
|
}
|
|
#[test]
|
|
fn channel_writer_sends_trimmed_line() {
|
|
use std::io::Write;
|
|
let (make_writer, mut rx) = TracingChannelMakeWriter::new();
|
|
let mut writer = make_writer.make_writer();
|
|
writer.write_all(b" hello world \n").unwrap();
|
|
let msg = rx.try_recv().unwrap();
|
|
assert_eq!(msg, "hello world");
|
|
}
|
|
#[test]
|
|
fn channel_writer_skips_empty() {
|
|
use std::io::Write;
|
|
let (make_writer, mut rx) = TracingChannelMakeWriter::new();
|
|
let mut writer = make_writer.make_writer();
|
|
writer.write_all(b" \n").unwrap();
|
|
writer.write_all(b"").unwrap();
|
|
assert!(rx.try_recv().is_err());
|
|
}
|
|
#[test]
|
|
fn channel_writer_returns_original_len() {
|
|
use std::io::Write;
|
|
let (make_writer, _rx) = TracingChannelMakeWriter::new();
|
|
let mut writer = make_writer.make_writer();
|
|
let input = b"hello\n";
|
|
let n = writer.write(input).unwrap();
|
|
assert_eq!(n, input.len());
|
|
}
|
|
#[test]
|
|
fn channel_writer_error_on_closed_receiver() {
|
|
use std::io::Write;
|
|
let (make_writer, rx) = TracingChannelMakeWriter::new();
|
|
let mut writer = make_writer.make_writer();
|
|
drop(rx);
|
|
let result = writer.write(b"hello");
|
|
assert!(result.is_err());
|
|
}
|
|
#[test]
|
|
fn channel_writer_multiple_writes() {
|
|
use std::io::Write;
|
|
let (make_writer, mut rx) = TracingChannelMakeWriter::new();
|
|
let mut writer = make_writer.make_writer();
|
|
writer.write_all(b"line 1\n").unwrap();
|
|
writer.write_all(b"line 2\n").unwrap();
|
|
writer.write_all(b"line 3\n").unwrap();
|
|
assert_eq!(rx.try_recv().unwrap(), "line 1");
|
|
assert_eq!(rx.try_recv().unwrap(), "line 2");
|
|
assert_eq!(rx.try_recv().unwrap(), "line 3");
|
|
}
|
|
#[test]
|
|
fn channel_writer_preserves_ansi() {
|
|
use std::io::Write;
|
|
let (make_writer, mut rx) = TracingChannelMakeWriter::new();
|
|
let mut writer = make_writer.make_writer();
|
|
let ansi_line = b"\x1b[32m INFO\x1b[0m hello\n";
|
|
writer.write_all(ansi_line).unwrap();
|
|
let msg = rx.try_recv().unwrap();
|
|
assert!(
|
|
msg.contains("\x1b[32m"),
|
|
"ANSI should be preserved: {msg:?}"
|
|
);
|
|
assert!(msg.contains("INFO"), "content should be preserved: {msg:?}");
|
|
}
|
|
#[test]
|
|
fn channel_to_model_end_to_end() {
|
|
use std::io::Write;
|
|
let (make_writer, mut rx) = TracingChannelMakeWriter::new();
|
|
let mut model = TracingModel::new(100, 10);
|
|
let mut writer = make_writer.make_writer();
|
|
writer
|
|
.write_all(b"\x1b[32m INFO\x1b[0m hello from tracing\n")
|
|
.unwrap();
|
|
writer
|
|
.write_all(b"\x1b[31mERROR\x1b[0m something went wrong\n")
|
|
.unwrap();
|
|
drop(writer);
|
|
while let Ok(line) = rx.try_recv() {
|
|
model.push(&line);
|
|
}
|
|
assert_eq!(model.len(), 2);
|
|
let slice = model.as_slice();
|
|
assert!(slice[0].search_text().contains("hello from tracing"));
|
|
assert!(slice[1].search_text().contains("something went wrong"));
|
|
let content: String = slice[0]
|
|
.content()
|
|
.spans
|
|
.iter()
|
|
.map(|s| s.content.as_ref())
|
|
.collect();
|
|
assert!(
|
|
content.contains("INFO") && content.contains("hello from tracing"),
|
|
"content should contain log text: {content:?}"
|
|
);
|
|
}
|
|
#[test]
|
|
fn channel_to_model_with_eviction() {
|
|
use std::io::Write;
|
|
let (make_writer, mut rx) = TracingChannelMakeWriter::new();
|
|
let mut model = TracingModel::new(3, 2);
|
|
let mut writer = make_writer.make_writer();
|
|
for i in 0..6 {
|
|
writer.write_all(format!("line {i}\n").as_bytes()).unwrap();
|
|
}
|
|
drop(writer);
|
|
while let Ok(line) = rx.try_recv() {
|
|
model.push(&line);
|
|
}
|
|
assert_eq!(model.len(), 3);
|
|
assert_eq!(model.as_slice()[0].plain(), "line 3");
|
|
assert_eq!(model.as_slice()[2].plain(), "line 5");
|
|
}
|
|
#[test]
|
|
fn make_writer_is_clone() {
|
|
let (mw, _rx) = TracingChannelMakeWriter::new();
|
|
let _mw2 = mw.clone();
|
|
}
|
|
}
|