feat(diagnostics): split error and shutdown messages

This commit is contained in:
Onur Özkan committed 2026-08-09 22:20:59 +03:00
1 parent 9646451645
commit afafd126d7
25 files changed
+608 -225

No files matched your search

+1 -1
View File
@@ -177,7 +177,7 @@ mod tests {
#[test]
fn validation_error_has_a_static_derived_message() {
assert_eq!(
CrashRecordError::BadChecksum.error_message().as_str(),
CrashRecordError::BadChecksum.static_error().path(),
"kaptan_log::crash::CrashRecordError::BadChecksum"
);
}
+64 -8
View File
@@ -11,7 +11,10 @@ use core::{
use log::{Level, LevelFilter, Log, Metadata, Record, SetLoggerError};
use crate::{EventHub, LogFilter, LogFlags, LogText, PublishOutcome, Severity};
use crate::{
ErrorAdditionalInformation, ErrorPath, EventHub, LogFilter, LogFlags, LogText, PublishOutcome,
Severity,
};
/// Saturating, process-local accounting for the optional global facade.
///
@@ -80,7 +83,30 @@ impl FacadeCounters {
struct IngressRecord {
severity: Severity,
flags: LogFlags,
text: LogText,
body: IngressBody,
}
#[derive(Clone, Copy)]
enum IngressBody {
Log {
message: LogText,
},
Error {
path: ErrorPath,
additional_information: ErrorAdditionalInformation,
},
}
impl IngressBody {
const fn is_truncated(self) -> bool {
match self {
Self::Log { message } => message.is_truncated(),
Self::Error {
path,
additional_information,
} => path.is_truncated() || additional_information.is_truncated(),
}
}
}
struct IngressQueue<const N: usize> {
@@ -206,7 +232,15 @@ impl<const N: usize> FacadeIngress<N> {
Some(None) => self.with_queue(IngressQueue::pop).flatten()?,
None => return None,
};
Some(hub.publish_text(record.severity, record.flags, record.text))
Some(match record.body {
IngressBody::Log { message } => {
hub.publish_text(record.severity, record.flags, message)
}
IngressBody::Error {
path,
additional_information,
} => hub.publish_error_text(record.flags, path, additional_information),
})
}
fn with_critical<R>(
@@ -303,14 +337,30 @@ impl<const N: usize> Log for GlobalLogger<N> {
} else {
LogFlags::NONE
};
let text = LogText::from_arguments(format_args!("[{}] {}", record.target(), record.args()));
if text.is_truncated() {
let body = if severity <= Severity::Error {
IngressBody::Error {
path: ErrorPath::from_message(record.target()),
additional_information: ErrorAdditionalInformation::from_arguments(format_args!(
"{}",
record.args()
)),
}
} else {
IngressBody::Log {
message: LogText::from_arguments(format_args!(
"[{}] {}",
record.target(),
record.args()
)),
}
};
if body.is_truncated() {
FacadeCounters::increment(&self.ingress.counters.truncated);
}
self.ingress.push(IngressRecord {
severity,
flags,
text,
body,
});
}
@@ -385,7 +435,11 @@ mod tests {
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
assert!(INGRESS.drain_one_into(&mut hub).is_some());
let pending = hub.peek().unwrap();
assert_eq!(pending.message().as_str(), "[test] link failed");
assert_eq!(pending.error_path().unwrap().as_str(), "test");
assert_eq!(
pending.additional_information().unwrap().as_str(),
"link failed"
);
assert_eq!(pending.severity(), Severity::Error);
assert_eq!(pending.channel(), RecordChannel::Error);
}
@@ -408,6 +462,7 @@ mod tests {
hub.peek()
.unwrap()
.message()
.unwrap()
.as_str()
.ends_with("armed configuration loaded")
);
@@ -505,7 +560,8 @@ mod tests {
assert!(INGRESS.drain_one_into(&mut hub).is_some());
let error = hub.peek().expect("error retained ahead of optional text");
assert_eq!(error.channel(), RecordChannel::Error);
assert_eq!(error.message().as_str(), "[test] critical");
assert_eq!(error.error_path().unwrap().as_str(), "test");
assert_eq!(error.additional_information().unwrap().as_str(), "critical");
assert_eq!(INGRESS.health().full, 0);
}
}
+77 -16
View File
@@ -3,8 +3,8 @@
use core::fmt;
use crate::{
DeliveryClass, ErrorReport, LogFilter, LogFlags, LogRecord, LogText, Severity,
StaticErrorMessage,
DeliveryClass, ErrorAdditionalInformation, ErrorPath, ErrorReport, LogFilter, LogFlags,
LogRecord, LogText, Severity, StaticError,
};
/// Outcome from one bounded diagnostic admission attempt.
@@ -164,7 +164,7 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
/// Retains one static derived error without formatting or exposing source
/// values. The adapter maps it to `KAPTAN_ERROR`.
pub fn publish_error<E: ErrorReport>(&mut self, flags: LogFlags, error: &E) -> PublishOutcome {
self.publish_error_static(flags, error.error_message())
self.publish_error_static(flags, error.static_error())
}
/// Retains one static mailbox error. This is the ISR-safe counterpart to
@@ -172,15 +172,27 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
pub fn publish_error_static(
&mut self,
flags: LogFlags,
message: &'static StaticErrorMessage,
error: &'static StaticError,
) -> PublishOutcome {
self.publish_text(
Severity::Error,
self.publish_error_text(
flags,
LogText::from_message(message.text()),
ErrorPath::from_message(error.path()),
ErrorAdditionalInformation::from_message(error.additional_information()),
)
}
/// Retains a preformatted error identity and optional detail. Both
/// components stay transport-neutral here; the MAVLink adapter enforces
/// its independent on-wire capacities.
pub fn publish_error_text(
&mut self,
flags: LogFlags,
path: ErrorPath,
additional_information: ErrorAdditionalInformation,
) -> PublishOutcome {
self.publish_record(LogRecord::error(flags, path, additional_information))
}
/// Retains one preformatted bounded text payload. Error-or-higher
/// severities are independently retained and sent as `KAPTAN_ERROR`.
pub fn publish_text(
@@ -189,16 +201,16 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
flags: LogFlags,
text: LogText,
) -> PublishOutcome {
let delivery = if severity <= Severity::Error {
DeliveryClass::Critical
} else {
DeliveryClass::BestEffort
};
let delivery = delivery_for(severity);
if delivery == DeliveryClass::BestEffort && !self.filter.allows(severity) {
HubHealth::increment(&mut self.health.filtered);
return PublishOutcome::Filtered;
}
let record = LogRecord::new(severity, flags, text);
self.publish_record(LogRecord::new(severity, flags, text))
}
fn publish_record(&mut self, record: LogRecord) -> PublishOutcome {
let delivery = record.delivery();
let outcome = match delivery {
DeliveryClass::Critical => self.critical.publish(record, false),
DeliveryClass::BestEffort => self.best_effort.publish(record, true),
@@ -250,11 +262,20 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
}
}
fn delivery_for(severity: Severity) -> DeliveryClass {
if severity <= Severity::Error {
DeliveryClass::Critical
} else {
DeliveryClass::BestEffort
}
}
#[cfg(test)]
mod tests {
use super::{EventHub, PublishOutcome, TransportAdmission};
use crate::{
DeliveryClass, LogFilter, LogFlags, LogText, RecordChannel, Severity, StaticErrorMessage,
DeliveryClass, ErrorAdditionalInformation, ErrorPath, LogFilter, LogFlags, LogText,
RecordChannel, Severity, StaticError,
};
#[test]
@@ -277,13 +298,14 @@ mod tests {
PublishOutcome::Queued
);
let record = hub.peek().unwrap();
assert_eq!(record.message().as_str(), "error");
assert_eq!(record.error_path().unwrap().as_str(), "error");
assert!(record.additional_information().unwrap().is_empty());
assert_eq!(record.channel(), RecordChannel::Error);
}
#[test]
fn static_messages_and_repeats_are_bounded() {
static FAULT: StaticErrorMessage = StaticErrorMessage::new("driver fault");
static FAULT: StaticError = StaticError::new("driver::Fault");
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
assert_eq!(
hub.publish_error_static(LogFlags::NONE, &FAULT),
@@ -296,6 +318,45 @@ mod tests {
let record = hub.peek().unwrap();
assert_eq!(record.occurrences, 2);
assert!(record.flags().contains(LogFlags::COALESCED));
assert_eq!(record.error_path().unwrap().as_str(), "driver::Fault");
assert!(record.additional_information().unwrap().is_empty());
}
#[test]
fn static_error_detail_needs_no_producer_formatting() {
static FAULT: StaticError =
StaticError::with_additional_information("driver::Dma::Fault", "stream 7");
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
assert_eq!(
hub.publish_error_static(LogFlags::NONE, &FAULT),
PublishOutcome::Queued
);
let record = hub.peek().unwrap();
assert_eq!(record.error_path().unwrap().as_str(), "driver::Dma::Fault");
assert_eq!(
record.additional_information().unwrap().as_str(),
"stream 7"
);
}
#[test]
fn error_identity_and_detail_are_independent_record_components() {
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
let _ = hub.publish_error_text(
LogFlags::NONE,
ErrorPath::from_message("driver::Icm42688::Transfer"),
ErrorAdditionalInformation::from_message("SPI DMA timeout"),
);
let record = hub.peek().unwrap();
assert_eq!(record.channel(), RecordChannel::Error);
assert_eq!(
record.error_path().unwrap().as_str(),
"driver::Icm42688::Transfer"
);
assert_eq!(
record.additional_information().unwrap().as_str(),
"SPI DMA timeout"
);
}
#[test]
+2 -4
View File
@@ -29,11 +29,9 @@ pub use hub::{EventHub, HubHealth, PublishOutcome, TransportAdmission};
pub use kaptan_log_derive::KaptanError;
pub use level::Severity;
pub use mailbox::StaticErrorMailbox;
pub use report::{
DeliveryClass, ErrorReport, LogFlags, LogRecord, RecordChannel, StaticErrorMessage,
};
pub use report::{DeliveryClass, ErrorReport, LogFlags, LogRecord, RecordChannel, StaticError};
pub use shutdown::{ShutdownReason, ShutdownReport};
pub use text::{LogText, MAXIMUM_LOG_TEXT_BYTES};
pub use text::{ErrorAdditionalInformation, ErrorPath, LogText, MAXIMUM_LOG_TEXT_BYTES};
#[cfg(feature = "facade")]
pub use facade::FacadeHealth;
+13 -13
View File
@@ -5,7 +5,7 @@ use core::{
sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, Ordering},
};
use crate::StaticErrorMessage;
use crate::StaticError;
/// One source-local, single-attempt static-fault mailbox.
///
@@ -13,7 +13,7 @@ use crate::StaticErrorMessage;
/// coalesced. They never loop, format, allocate, lock, or encode a frame. A
/// worker drains the exact message pointer and flag later.
pub struct StaticErrorMailbox {
message: AtomicPtr<StaticErrorMessage>,
error: AtomicPtr<StaticError>,
coalesced: AtomicBool,
coalesced_total: AtomicU32,
}
@@ -22,19 +22,19 @@ impl StaticErrorMailbox {
/// Creates an empty mailbox.
pub const fn new() -> Self {
Self {
message: AtomicPtr::new(ptr::null_mut()),
error: AtomicPtr::new(ptr::null_mut()),
coalesced: AtomicBool::new(false),
coalesced_total: AtomicU32::new(0),
}
}
/// Records one static message with one bounded atomic attempt.
pub fn record(&self, message: &'static StaticErrorMessage) {
pub fn record(&self, error: &'static StaticError) {
if self
.message
.error
.compare_exchange(
ptr::null_mut(),
core::ptr::from_ref(message).cast_mut(),
core::ptr::from_ref(error).cast_mut(),
Ordering::AcqRel,
Ordering::Acquire,
)
@@ -46,13 +46,13 @@ impl StaticErrorMailbox {
}
/// Removes the retained first message and its coalesced marker.
pub fn take(&self) -> (Option<&'static StaticErrorMessage>, bool) {
let message = self.message.swap(ptr::null_mut(), Ordering::AcqRel);
pub fn take(&self) -> (Option<&'static StaticError>, bool) {
let error = self.error.swap(ptr::null_mut(), Ordering::AcqRel);
let coalesced = self.coalesced.swap(false, Ordering::AcqRel);
// SAFETY: `record` stores only pointers to static messages and the
// atomic swap transfers the exact pointer to this one worker.
let message = unsafe { message.as_ref() };
(message, coalesced)
let error = unsafe { error.as_ref() };
(error, coalesced)
}
/// Total later-fault coalesces since startup. This is local producer
@@ -81,12 +81,12 @@ impl Default for StaticErrorMailbox {
#[cfg(test)]
mod tests {
use super::StaticErrorMailbox;
use crate::StaticErrorMessage;
use crate::StaticError;
#[test]
fn source_mailbox_keeps_the_first_static_message_and_marks_later_faults() {
static FIRST: StaticErrorMessage = StaticErrorMessage::new("first");
static SECOND: StaticErrorMessage = StaticErrorMessage::new("second");
static FIRST: StaticError = StaticError::new("first");
static SECOND: StaticError = StaticError::new("second");
let mailbox = StaticErrorMailbox::new();
mailbox.record(&FIRST);
mailbox.record(&SECOND);
+139 -49
View File
@@ -1,37 +1,52 @@
//! Bounded human-readable diagnostic records.
use crate::{LogText, Severity};
use crate::{ErrorAdditionalInformation, ErrorPath, LogText, Severity};
/// Static message generated for one error type/variant.
/// Static identity and optional detail for one error.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct StaticErrorMessage {
text: &'static str,
pub struct StaticError {
path: &'static str,
additional_information: &'static str,
}
impl StaticErrorMessage {
/// Creates one compile-time error message.
pub const fn new(text: &'static str) -> Self {
Self { text }
impl StaticError {
/// Creates one compile-time error identity without additional detail.
pub const fn new(path: &'static str) -> Self {
Self {
path,
additional_information: "",
}
}
/// Stable human-readable message text.
pub const fn text(self) -> &'static str {
self.text
/// Creates one compile-time error identity with static additional detail.
pub const fn with_additional_information(
path: &'static str,
additional_information: &'static str,
) -> Self {
Self {
path,
additional_information,
}
}
/// Alias used by the error derive's [`core::fmt::Display`] implementation.
pub const fn as_str(self) -> &'static str {
self.text
/// Stable human-readable error identity.
pub const fn path(self) -> &'static str {
self.path
}
/// Optional static human-facing detail.
pub const fn additional_information(self) -> &'static str {
self.additional_information
}
}
/// Implemented by [`crate::KaptanError`] derives.
///
/// This trait is deliberately pure: obtaining a message never allocates,
/// This trait is deliberately pure: obtaining error identity never allocates,
/// publishes telemetry, changes safety state, or serializes generic sources.
pub trait ErrorReport {
/// Returns the static type/variant message for this error value.
fn error_message(&self) -> &'static StaticErrorMessage;
/// Returns static identity and optional detail for this error value.
fn static_error(&self) -> &'static StaticError;
}
/// Bounded MAVLink log flags.
@@ -81,6 +96,17 @@ pub enum RecordChannel {
Error,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RecordBody {
Log {
message: LogText,
},
Error {
path: ErrorPath,
additional_information: ErrorAdditionalInformation,
},
}
/// Priority selected internally from a diagnostic level.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DeliveryClass {
@@ -95,36 +121,58 @@ pub enum DeliveryClass {
pub struct LogRecord {
severity: Severity,
flags: LogFlags,
message: LogText,
channel: RecordChannel,
/// Adjacent identical messages retained as one record.
body: RecordBody,
/// Adjacent equivalent diagnostics retained as one record.
pub occurrences: u32,
delivery: DeliveryClass,
}
impl LogRecord {
/// Creates one bounded record. Error-or-higher severities use
/// [`RecordChannel::Error`]; ordinary severity records use
/// [`RecordChannel::Log`].
pub fn new(severity: Severity, mut flags: LogFlags, message: LogText) -> Self {
if message.is_truncated() {
flags = flags.with(LogFlags::TRUNCATED);
/// Creates one bounded diagnostic record. Error-or-higher severities use
/// their supplied text as the error path with no additional information;
/// ordinary severity records use `KAPTAN_LOG` message text.
pub fn new(severity: Severity, flags: LogFlags, message: LogText) -> Self {
if severity <= Severity::Error {
let flags = with_truncation(flags, message);
return Self::error_with_severity(
severity,
flags,
ErrorPath::from_message(message.as_str()),
ErrorAdditionalInformation::new(),
);
}
Self {
severity,
flags,
message,
channel: if severity <= Severity::Error {
RecordChannel::Error
} else {
RecordChannel::Log
flags: with_truncation(flags, message),
body: RecordBody::Log { message },
occurrences: 1,
}
}
/// Creates an error-channel record with independently bounded identity
/// and optional additional information.
pub fn error(
flags: LogFlags,
path: ErrorPath,
additional_information: ErrorAdditionalInformation,
) -> Self {
Self::error_with_severity(Severity::Error, flags, path, additional_information)
}
fn error_with_severity(
severity: Severity,
flags: LogFlags,
path: ErrorPath,
additional_information: ErrorAdditionalInformation,
) -> Self {
debug_assert!(severity <= Severity::Error);
Self {
severity,
flags: flags_for_error(flags, path, additional_information),
body: RecordBody::Error {
path,
additional_information,
},
occurrences: 1,
delivery: if severity <= Severity::Error {
DeliveryClass::Critical
} else {
DeliveryClass::BestEffort
},
}
}
@@ -138,29 +186,53 @@ impl LogRecord {
self.flags
}
/// Fixed formatted text.
pub const fn message(&self) -> &LogText {
&self.message
/// Fixed text carried by an ordinary `KAPTAN_LOG` record.
pub const fn message(&self) -> Option<&LogText> {
match &self.body {
RecordBody::Log { message } => Some(message),
RecordBody::Error { .. } => None,
}
}
/// Fixed identity path carried by a `KAPTAN_ERROR` record.
pub const fn error_path(&self) -> Option<&ErrorPath> {
match &self.body {
RecordBody::Log { .. } => None,
RecordBody::Error { path, .. } => Some(path),
}
}
/// Fixed optional information carried by a `KAPTAN_ERROR` record.
pub const fn additional_information(&self) -> Option<&ErrorAdditionalInformation> {
match &self.body {
RecordBody::Log { .. } => None,
RecordBody::Error {
additional_information,
..
} => Some(additional_information),
}
}
/// Custom MAVLink diagnostic family selected for this record.
pub const fn channel(&self) -> RecordChannel {
self.channel
match self.body {
RecordBody::Log { .. } => RecordChannel::Log,
RecordBody::Error { .. } => RecordChannel::Error,
}
}
/// Internal admission priority.
pub const fn delivery(&self) -> DeliveryClass {
self.delivery
match self.body {
RecordBody::Log { .. } => DeliveryClass::BestEffort,
RecordBody::Error { .. } => DeliveryClass::Critical,
}
}
/// Tests whether two pending records can coalesce without hiding a new
/// message, severity, or delivery class.
/// payload, severity, or delivery class.
pub fn is_equivalent_to(&self, other: &Self) -> bool {
self.channel == other.channel
&& self.severity == other.severity
&& self.flags == other.flags
&& self.message == other.message
&& self.delivery == other.delivery
self.severity == other.severity && self.flags == other.flags && self.body == other.body
}
pub(crate) fn increment_occurrences(&mut self) {
@@ -168,3 +240,21 @@ impl LogRecord {
self.flags = self.flags.with(LogFlags::COALESCED);
}
}
fn with_truncation(mut flags: LogFlags, value: LogText) -> LogFlags {
if value.is_truncated() {
flags = flags.with(LogFlags::TRUNCATED);
}
flags
}
fn flags_for_error(
mut flags: LogFlags,
path: ErrorPath,
additional_information: ErrorAdditionalInformation,
) -> LogFlags {
if path.is_truncated() || additional_information.is_truncated() {
flags = flags.with(LogFlags::TRUNCATED);
}
flags
}
+11 -4
View File
@@ -1,11 +1,11 @@
//! Fixed-capacity UTF-8 log text.
//! Fixed-capacity UTF-8 diagnostic text.
use core::fmt::{self, Write};
/// Maximum UTF-8 bytes carried by one Kaptan log record.
/// Maximum bytes retained in one transport-neutral diagnostic text component.
pub const MAXIMUM_LOG_TEXT_BYTES: usize = 160;
/// Bounded formatted text with an explicit truncation marker.
/// Fixed UTF-8 diagnostic text with an explicit truncation marker.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct LogText {
bytes: [u8; MAXIMUM_LOG_TEXT_BYTES],
@@ -13,8 +13,15 @@ pub struct LogText {
truncated: bool,
}
/// Transport-neutral error identity path. The MAVLink adapter applies its
/// separate 96-byte wire bound.
pub type ErrorPath = LogText;
/// Transport-neutral optional error detail. The MAVLink adapter applies its
/// separate 64-byte wire bound.
pub type ErrorAdditionalInformation = LogText;
impl LogText {
/// Creates an empty bounded log message.
/// Creates empty fixed text storage.
pub const fn new() -> Self {
Self {
bytes: [0; MAXIMUM_LOG_TEXT_BYTES],