feat(obs): add shared diagnostics models

Add the observability vocabulary needed for phase-1 frame logging and client
status reporting. Runtime crates can now emit structured events without each
binary inventing separate field names for the same tunnel state.

The new models cover frame direction, action, drop reason, parsed Ethernet frame
logs, malformed frame logs, tunnel counters, relay/QUIC/TAP client diagnostics,
and user-facing diagnostic messages. TunnelStats now lives in lanparty-obs and
is re-exported by lanparty-ctrl so stats remain one shared type whether they are
logged locally or carried over the control stream.

This still does not add logging sinks or tracing integration; those should be
wired in when the relay, gateway, and client loops exist.

Test Plan:
- cargo fmt --check
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings

Refs: PLAN.md Logging / diagnostics
This commit is contained in:
2026-05-21 17:17:44 +02:00
parent a9c143e447
commit 7aeaa0aeb9
6 changed files with 460 additions and 66 deletions
+2
View File
@@ -4,3 +4,5 @@ version.workspace = true
edition.workspace = true
[dependencies]
lanparty-proto = { path = "../lanparty-proto" }
serde.workspace = true
+443 -5
View File
@@ -1,5 +1,380 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
//! Shared diagnostics and structured logging models.
//!
//! Runtime crates can convert these values into `tracing` fields, JSON logs, or
//! user-facing status lines without each component inventing its own vocabulary.
use std::net::IpAddr;
use lanparty_proto::{EthernetFrame, MacAddr};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FrameDirection {
RemoteToLan,
LanToRemote,
RelayIngress,
RelayEgress,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FrameAction {
Forwarded,
Dropped,
Filtered,
RateLimited,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DropReason {
Malformed,
JumboFrame,
UnauthorizedSourceMac,
DuplicateMac,
ControlPlaneEtherType,
DhcpServerReply,
Ipv6RouterAdvertisement,
UnknownDestination,
RateLimit,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct FrameLog {
direction: FrameDirection,
source_mac: Option<MacAddr>,
destination_mac: Option<MacAddr>,
ethertype_or_len: Option<u16>,
frame_len: usize,
peer_id: Option<u32>,
action: FrameAction,
drop_reason: Option<DropReason>,
}
impl FrameLog {
#[must_use]
pub fn from_ethernet(
direction: FrameDirection,
peer_id: Option<u32>,
action: FrameAction,
drop_reason: Option<DropReason>,
frame: EthernetFrame<'_>,
) -> Self {
Self {
direction,
source_mac: Some(frame.source()),
destination_mac: Some(frame.destination()),
ethertype_or_len: Some(frame.ethertype_or_len()),
frame_len: frame.len(),
peer_id,
action,
drop_reason,
}
}
#[must_use]
pub const fn malformed(
direction: FrameDirection,
peer_id: Option<u32>,
frame_len: usize,
) -> Self {
Self {
direction,
source_mac: None,
destination_mac: None,
ethertype_or_len: None,
frame_len,
peer_id,
action: FrameAction::Dropped,
drop_reason: Some(DropReason::Malformed),
}
}
#[must_use]
pub const fn direction(&self) -> FrameDirection {
self.direction
}
#[must_use]
pub const fn source_mac(&self) -> Option<MacAddr> {
self.source_mac
}
#[must_use]
pub const fn destination_mac(&self) -> Option<MacAddr> {
self.destination_mac
}
#[must_use]
pub const fn ethertype_or_len(&self) -> Option<u16> {
self.ethertype_or_len
}
#[must_use]
pub const fn frame_len(&self) -> usize {
self.frame_len
}
#[must_use]
pub const fn peer_id(&self) -> Option<u32> {
self.peer_id
}
#[must_use]
pub const fn action(&self) -> FrameAction {
self.action
}
#[must_use]
pub const fn drop_reason(&self) -> Option<DropReason> {
self.drop_reason
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
pub struct TunnelStats {
ethernet_frames_tx: u64,
ethernet_frames_rx: u64,
datagrams_tx: u64,
datagrams_rx: u64,
dropped_frames: u64,
malformed_frames: u64,
}
impl TunnelStats {
#[must_use]
pub const fn new(
ethernet_frames_tx: u64,
ethernet_frames_rx: u64,
datagrams_tx: u64,
datagrams_rx: u64,
dropped_frames: u64,
malformed_frames: u64,
) -> Self {
Self {
ethernet_frames_tx,
ethernet_frames_rx,
datagrams_tx,
datagrams_rx,
dropped_frames,
malformed_frames,
}
}
#[must_use]
pub const fn ethernet_frames_tx(&self) -> u64 {
self.ethernet_frames_tx
}
#[must_use]
pub const fn ethernet_frames_rx(&self) -> u64 {
self.ethernet_frames_rx
}
#[must_use]
pub const fn datagrams_tx(&self) -> u64 {
self.datagrams_tx
}
#[must_use]
pub const fn datagrams_rx(&self) -> u64 {
self.datagrams_rx
}
#[must_use]
pub const fn dropped_frames(&self) -> u64 {
self.dropped_frames
}
#[must_use]
pub const fn malformed_frames(&self) -> u64 {
self.malformed_frames
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct RelayDiagnostics {
reachable: bool,
route_pinned: bool,
}
impl RelayDiagnostics {
#[must_use]
pub const fn new(reachable: bool, route_pinned: bool) -> Self {
Self {
reachable,
route_pinned,
}
}
#[must_use]
pub const fn reachable(&self) -> bool {
self.reachable
}
#[must_use]
pub const fn route_pinned(&self) -> bool {
self.route_pinned
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct QuicDiagnostics {
datagram_supported: bool,
max_datagram_size: Option<u16>,
}
impl QuicDiagnostics {
#[must_use]
pub const fn new(datagram_supported: bool, max_datagram_size: Option<u16>) -> Self {
Self {
datagram_supported,
max_datagram_size,
}
}
#[must_use]
pub const fn datagram_supported(&self) -> bool {
self.datagram_supported
}
#[must_use]
pub const fn max_datagram_size(&self) -> Option<u16> {
self.max_datagram_size
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct TapDiagnostics {
adapter_found: bool,
mac: Option<MacAddr>,
mtu: Option<u16>,
ip: Option<IpAddr>,
}
impl TapDiagnostics {
#[must_use]
pub const fn new(
adapter_found: bool,
mac: Option<MacAddr>,
mtu: Option<u16>,
ip: Option<IpAddr>,
) -> Self {
Self {
adapter_found,
mac,
mtu,
ip,
}
}
#[must_use]
pub const fn adapter_found(&self) -> bool {
self.adapter_found
}
#[must_use]
pub const fn mac(&self) -> Option<MacAddr> {
self.mac
}
#[must_use]
pub const fn mtu(&self) -> Option<u16> {
self.mtu
}
#[must_use]
pub const fn ip(&self) -> Option<IpAddr> {
self.ip
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct ClientDiagnostics {
relay: RelayDiagnostics,
quic: QuicDiagnostics,
tap: TapDiagnostics,
stats: TunnelStats,
}
impl ClientDiagnostics {
#[must_use]
pub const fn new(
relay: RelayDiagnostics,
quic: QuicDiagnostics,
tap: TapDiagnostics,
stats: TunnelStats,
) -> Self {
Self {
relay,
quic,
tap,
stats,
}
}
#[must_use]
pub const fn relay(&self) -> &RelayDiagnostics {
&self.relay
}
#[must_use]
pub const fn quic(&self) -> &QuicDiagnostics {
&self.quic
}
#[must_use]
pub const fn tap(&self) -> &TapDiagnostics {
&self.tap
}
#[must_use]
pub const fn stats(&self) -> &TunnelStats {
&self.stats
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum UserDiagnosticLevel {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct UserDiagnostic {
level: UserDiagnosticLevel,
message: String,
}
impl UserDiagnostic {
#[must_use]
pub fn new(level: UserDiagnosticLevel, message: impl Into<String>) -> Self {
Self {
level,
message: message.into(),
}
}
#[must_use]
pub const fn level(&self) -> UserDiagnosticLevel {
self.level
}
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
pub enum DiagnosticEvent {
Frame(FrameLog),
Client(ClientDiagnostics),
Stats(TunnelStats),
User(UserDiagnostic),
}
#[cfg(test)]
@@ -7,8 +382,71 @@ mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
fn builds_frame_log_from_ethernet_header() {
let bytes = [
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x02, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0x08, 0x06,
];
let frame = EthernetFrame::parse(&bytes).unwrap();
let log = FrameLog::from_ethernet(
FrameDirection::RemoteToLan,
Some(7),
FrameAction::Forwarded,
None,
frame,
);
assert_eq!(log.direction(), FrameDirection::RemoteToLan);
assert_eq!(
log.source_mac(),
Some(MacAddr::new([0x02, 0xaa, 0xbb, 0xcc, 0xdd, 0xee]))
);
assert_eq!(log.destination_mac(), Some(MacAddr::BROADCAST));
assert_eq!(log.ethertype_or_len(), Some(0x0806));
assert_eq!(log.frame_len(), bytes.len());
assert_eq!(log.peer_id(), Some(7));
assert_eq!(log.action(), FrameAction::Forwarded);
assert_eq!(log.drop_reason(), None);
}
#[test]
fn builds_malformed_frame_log_without_header_fields() {
let log = FrameLog::malformed(FrameDirection::RelayIngress, Some(3), 8);
assert_eq!(log.direction(), FrameDirection::RelayIngress);
assert_eq!(log.source_mac(), None);
assert_eq!(log.destination_mac(), None);
assert_eq!(log.ethertype_or_len(), None);
assert_eq!(log.frame_len(), 8);
assert_eq!(log.peer_id(), Some(3));
assert_eq!(log.action(), FrameAction::Dropped);
assert_eq!(log.drop_reason(), Some(DropReason::Malformed));
}
#[test]
fn exposes_client_diagnostics() {
let mac = MacAddr::new([0x02, 1, 2, 3, 4, 5]);
let stats = TunnelStats::new(1, 2, 3, 4, 5, 6);
let diagnostics = ClientDiagnostics::new(
RelayDiagnostics::new(true, true),
QuicDiagnostics::new(true, Some(1400)),
TapDiagnostics::new(
true,
Some(mac),
Some(1200),
Some("10.73.42.51".parse().unwrap()),
),
stats,
);
assert!(diagnostics.relay().reachable());
assert!(diagnostics.relay().route_pinned());
assert!(diagnostics.quic().datagram_supported());
assert_eq!(diagnostics.quic().max_datagram_size(), Some(1400));
assert!(diagnostics.tap().adapter_found());
assert_eq!(diagnostics.tap().mac(), Some(mac));
assert_eq!(diagnostics.tap().mtu(), Some(1200));
assert_eq!(diagnostics.tap().ip().unwrap().to_string(), "10.73.42.51");
assert_eq!(diagnostics.stats().dropped_frames(), 5);
}
}