1use chrono::Utc;
7use once_cell::sync::Lazy;
8use serde::Serialize;
9use serde_json::{Map, Value, json};
10use std::fmt;
11use std::sync::OnceLock;
12use thiserror::Error;
13use uuid::Uuid;
14
15pub const CHANNEL_OBSERVABILITY: u8 = 0b0001;
17pub const CHANNEL_AUDIT: u8 = 0b0010;
19pub const CHANNEL_SECURITY: u8 = 0b0100;
21pub const CHANNEL_OPERATIONS: u8 = 0b1000;
23
24const CHANNEL_MASK_ALL: u8 =
25 CHANNEL_OBSERVABILITY | CHANNEL_AUDIT | CHANNEL_SECURITY | CHANNEL_OPERATIONS;
26
27static FALLBACK_SOURCE_NODE: Lazy<Option<String>> = Lazy::new(detect_source_node);
28
29#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
31pub struct RecordId(pub String);
32
33impl RecordId {
34 pub fn new() -> Self {
36 Self(Uuid::new_v4().to_string())
37 }
38}
39
40impl Default for RecordId {
41 fn default() -> Self {
42 Self::new()
43 }
44}
45
46impl fmt::Display for RecordId {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 self.0.fmt(f)
49 }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
54#[serde(rename_all = "lowercase")]
55pub enum RecordLevel {
56 Trace,
57 Debug,
58 #[default]
59 Info,
60 Warn,
61 Error,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
66#[serde(rename_all = "snake_case")]
67pub enum Outcome {
68 Success,
69 Failure,
70 Denied,
71 Timeout,
72 #[default]
73 Unknown,
74}
75
76#[derive(Debug, Clone, Serialize)]
78pub struct Common {
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub actor: Option<String>,
81 pub outcome: Outcome,
82 pub level: RecordLevel,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub trace_id: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub span_id: Option<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub request_id: Option<String>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub source_service: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub source_node: Option<String>,
93}
94
95impl Default for Common {
96 fn default() -> Self {
97 Self {
98 actor: None,
99 outcome: Outcome::Unknown,
100 level: RecordLevel::Info,
101 trace_id: None,
102 span_id: None,
103 request_id: None,
104 source_service: None,
105 source_node: None,
106 }
107 }
108}
109
110#[derive(Debug, Clone, Serialize, Default)]
112pub struct ObservabilityPayload {
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub summary: Option<String>,
116 #[serde(skip_serializing_if = "Option::is_none")]
118 pub component: Option<String>,
119 #[serde(skip_serializing_if = "Option::is_none")]
121 pub operation: Option<String>,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub protocol: Option<Protocol>,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub route: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub method: Option<String>,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub status_code: Option<u16>,
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub duration_ms: Option<u64>,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
141#[serde(rename_all = "snake_case")]
142pub enum Protocol {
143 #[default]
144 Internal,
145 Http,
146 Grpc,
147 #[serde(rename = "websocket")]
148 WebSocket,
149 Stun,
150 Turn,
151 Tcp,
152 Udp,
153}
154
155impl ObservabilityPayload {
156 fn validate(&self) -> Result<(), RecordingError> {
157 if self.summary.is_none()
158 && self.component.is_none()
159 && self.operation.is_none()
160 && self.protocol.is_none()
161 && self.route.is_none()
162 && self.method.is_none()
163 && self.status_code.is_none()
164 && self.duration_ms.is_none()
165 {
166 return Err(RecordingError::EmptyPayload {
167 channel: "observability",
168 });
169 }
170
171 validate_optional_non_empty("observability", "summary", &self.summary)?;
172 validate_optional_non_empty("observability", "component", &self.component)?;
173 validate_optional_non_empty("observability", "operation", &self.operation)?;
174 validate_optional_non_empty("observability", "route", &self.route)?;
175 validate_optional_non_empty("observability", "method", &self.method)?;
176
177 Ok(())
178 }
179}
180
181#[derive(Debug, Clone, Serialize)]
183pub struct AuditPayload {
184 pub action: String,
186 pub resource: String,
188 #[serde(skip_serializing_if = "Option::is_none")]
190 pub resource_id: Option<String>,
191 #[serde(skip_serializing_if = "Option::is_none")]
193 pub reason: Option<String>,
194 #[serde(skip_serializing_if = "Option::is_none")]
196 pub remote_addr: Option<String>,
197 #[serde(skip_serializing_if = "Option::is_none")]
199 pub session_id: Option<String>,
200 #[serde(skip_serializing_if = "Option::is_none")]
202 pub before: Option<Value>,
203 #[serde(skip_serializing_if = "Option::is_none")]
205 pub after: Option<Value>,
206}
207
208impl AuditPayload {
209 fn validate(&self) -> Result<(), RecordingError> {
210 validate_required_non_empty("audit", "action", &self.action)?;
211 validate_required_non_empty("audit", "resource", &self.resource)?;
212 validate_optional_non_empty("audit", "resource_id", &self.resource_id)?;
213 validate_optional_non_empty("audit", "reason", &self.reason)?;
214 validate_optional_non_empty("audit", "remote_addr", &self.remote_addr)?;
215 validate_optional_non_empty("audit", "session_id", &self.session_id)?;
216
217 Ok(())
218 }
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
223#[serde(rename_all = "snake_case")]
224pub enum SecuritySeverity {
225 Low,
226 #[default]
227 Medium,
228 High,
229 Critical,
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
236pub enum ObservabilityFilter {
237 Off,
238 #[default]
239 Digest,
240 Detailed,
241 Full,
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
246pub enum AuditFilter {
247 Off,
248 #[default]
249 Mutations,
250 All,
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
255pub enum SecurityFilter {
256 Off,
257 Critical,
258 High,
259 Medium,
260 #[default]
261 All,
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
266pub enum OperationsFilter {
267 Off,
268 #[default]
269 Lifecycle,
270 Detailed,
271}
272
273#[derive(Debug, Clone, Default)]
275pub struct ChannelFilters {
276 pub observability: ObservabilityFilter,
277 pub audit: AuditFilter,
278 pub security: SecurityFilter,
279 pub operations: OperationsFilter,
280}
281
282static CHANNEL_FILTERS: OnceLock<ChannelFilters> = OnceLock::new();
283
284pub fn set_channel_filters(filters: ChannelFilters) {
286 let _ = CHANNEL_FILTERS.set(filters);
287}
288
289pub fn get_channel_filters() -> &'static ChannelFilters {
291 static DEFAULT: Lazy<ChannelFilters> = Lazy::new(ChannelFilters::default);
292 CHANNEL_FILTERS.get().unwrap_or(&DEFAULT)
293}
294
295pub fn parse_observability_filter(s: &str) -> ObservabilityFilter {
297 match s {
298 "off" => ObservabilityFilter::Off,
299 "digest" => ObservabilityFilter::Digest,
300 "detailed" => ObservabilityFilter::Detailed,
301 "full" => ObservabilityFilter::Full,
302 _ => ObservabilityFilter::default(),
303 }
304}
305
306pub fn parse_audit_filter(s: &str) -> AuditFilter {
308 match s {
309 "off" => AuditFilter::Off,
310 "mutations" => AuditFilter::Mutations,
311 "all" => AuditFilter::All,
312 _ => AuditFilter::default(),
313 }
314}
315
316pub fn parse_security_filter(s: &str) -> SecurityFilter {
318 match s {
319 "off" => SecurityFilter::Off,
320 "critical" => SecurityFilter::Critical,
321 "high" => SecurityFilter::High,
322 "medium" => SecurityFilter::Medium,
323 "all" => SecurityFilter::All,
324 _ => SecurityFilter::default(),
325 }
326}
327
328pub fn parse_operations_filter(s: &str) -> OperationsFilter {
330 match s {
331 "off" => OperationsFilter::Off,
332 "lifecycle" => OperationsFilter::Lifecycle,
333 "detailed" => OperationsFilter::Detailed,
334 _ => OperationsFilter::default(),
335 }
336}
337
338const AUDIT_READ_PREFIXES: &[&str] = &["read", "get", "list", "query", "check", "view", "describe"];
342
343fn is_audit_read_action(action: &str) -> bool {
344 let lower = action.to_ascii_lowercase();
345 AUDIT_READ_PREFIXES
346 .iter()
347 .any(|prefix| lower.starts_with(prefix))
348}
349
350fn should_emit_observability(common: &Common, filter: ObservabilityFilter) -> bool {
351 match filter {
352 ObservabilityFilter::Off => false,
353 ObservabilityFilter::Digest => matches!(
354 common.level,
355 RecordLevel::Info | RecordLevel::Warn | RecordLevel::Error
356 ),
357 ObservabilityFilter::Detailed => matches!(
358 common.level,
359 RecordLevel::Debug | RecordLevel::Info | RecordLevel::Warn | RecordLevel::Error
360 ),
361 ObservabilityFilter::Full => true,
362 }
363}
364
365fn should_emit_audit(payload: &AuditPayload, filter: AuditFilter) -> bool {
366 match filter {
367 AuditFilter::Off => false,
368 AuditFilter::Mutations => !is_audit_read_action(&payload.action),
369 AuditFilter::All => true,
370 }
371}
372
373fn should_emit_security(payload: &SecurityPayload, filter: SecurityFilter) -> bool {
374 match filter {
375 SecurityFilter::Off => false,
376 SecurityFilter::Critical => matches!(payload.severity, SecuritySeverity::Critical),
377 SecurityFilter::High => matches!(
378 payload.severity,
379 SecuritySeverity::High | SecuritySeverity::Critical
380 ),
381 SecurityFilter::Medium => matches!(
382 payload.severity,
383 SecuritySeverity::Medium | SecuritySeverity::High | SecuritySeverity::Critical
384 ),
385 SecurityFilter::All => true,
386 }
387}
388
389fn should_emit_operations(common: &Common, filter: OperationsFilter) -> bool {
390 match filter {
391 OperationsFilter::Off => false,
392 OperationsFilter::Lifecycle => matches!(
393 common.level,
394 RecordLevel::Info | RecordLevel::Warn | RecordLevel::Error
395 ),
396 OperationsFilter::Detailed => true,
397 }
398}
399
400#[derive(Debug, Clone, Serialize)]
402pub struct SecurityPayload {
403 pub control: String,
405 pub severity: SecuritySeverity,
407 #[serde(skip_serializing_if = "Option::is_none")]
409 pub category: Option<String>,
410 #[serde(skip_serializing_if = "Option::is_none")]
412 pub subject: Option<String>,
413 #[serde(skip_serializing_if = "Option::is_none")]
415 pub source_addr: Option<String>,
416 #[serde(skip_serializing_if = "Option::is_none")]
418 pub destination_addr: Option<String>,
419 #[serde(skip_serializing_if = "Option::is_none")]
421 pub evidence: Option<Value>,
422}
423
424impl SecurityPayload {
425 fn validate(&self) -> Result<(), RecordingError> {
426 validate_required_non_empty("security", "control", &self.control)?;
427 validate_optional_non_empty("security", "category", &self.category)?;
428 validate_optional_non_empty("security", "subject", &self.subject)?;
429 validate_optional_non_empty("security", "source_addr", &self.source_addr)?;
430 validate_optional_non_empty("security", "destination_addr", &self.destination_addr)?;
431
432 Ok(())
433 }
434}
435
436#[derive(Debug, Clone, Serialize)]
438pub struct OperationsPayload {
439 pub operation: String,
441 #[serde(skip_serializing_if = "Option::is_none")]
443 pub component: Option<String>,
444 #[serde(skip_serializing_if = "Option::is_none")]
446 pub runbook: Option<String>,
447 #[serde(skip_serializing_if = "Option::is_none")]
449 pub ticket: Option<String>,
450 #[serde(skip_serializing_if = "Option::is_none")]
452 pub change_id: Option<String>,
453 #[serde(skip_serializing_if = "Option::is_none")]
455 pub attempt: Option<u32>,
456 #[serde(skip_serializing_if = "Option::is_none")]
458 pub duration_ms: Option<u64>,
459}
460
461impl OperationsPayload {
462 fn validate(&self) -> Result<(), RecordingError> {
463 validate_required_non_empty("operations", "operation", &self.operation)?;
464 validate_optional_non_empty("operations", "component", &self.component)?;
465 validate_optional_non_empty("operations", "runbook", &self.runbook)?;
466 validate_optional_non_empty("operations", "ticket", &self.ticket)?;
467 validate_optional_non_empty("operations", "change_id", &self.change_id)?;
468
469 Ok(())
470 }
471}
472
473#[derive(Debug, Clone)]
474struct Callsite {
475 file: &'static str,
476 line: u32,
477 module_path: &'static str,
478}
479
480fn enrich_common(mut common: Common, callsite: &Callsite) -> Common {
481 common.actor = sanitize_optional_string(common.actor);
482 common.request_id = sanitize_optional_string(common.request_id);
483
484 let explicit_trace_id = sanitize_optional_string(common.trace_id);
485 let explicit_span_id = sanitize_optional_string(common.span_id);
486 let (auto_trace_id, auto_span_id) = if explicit_trace_id.is_none() || explicit_span_id.is_none()
487 {
488 detect_current_trace_context()
489 } else {
490 (None, None)
491 };
492 common.trace_id = explicit_trace_id.or(auto_trace_id);
493 common.span_id = explicit_span_id.or(auto_span_id);
494
495 let explicit_source_service = sanitize_optional_string(common.source_service);
496 common.source_service = explicit_source_service.or_else(|| detect_source_service(callsite));
497
498 let explicit_source_node = sanitize_optional_string(common.source_node);
499 common.source_node = explicit_source_node.or_else(|| FALLBACK_SOURCE_NODE.as_ref().cloned());
500
501 common
502}
503
504fn sanitize_optional_string(value: Option<String>) -> Option<String> {
505 value.and_then(sanitize_string)
506}
507
508fn sanitize_string(value: String) -> Option<String> {
509 let trimmed = value.trim();
510 if trimmed.is_empty() {
511 return None;
512 }
513
514 Some(trimmed.to_string())
515}
516
517fn detect_source_service(callsite: &Callsite) -> Option<String> {
518 let source_service = std::env::var("ACTRIX_SOURCE_SERVICE")
519 .ok()
520 .and_then(sanitize_string);
521 if source_service.is_some() {
522 return source_service;
523 }
524
525 if callsite.module_path == "unknown" {
526 return None;
527 }
528
529 callsite
530 .module_path
531 .split("::")
532 .next()
533 .and_then(|value| sanitize_string(value.to_string()))
534}
535
536fn detect_source_node() -> Option<String> {
537 std::env::var("ACTRIX_SOURCE_NODE")
538 .ok()
539 .and_then(sanitize_string)
540 .or_else(|| std::env::var("HOSTNAME").ok().and_then(sanitize_string))
541 .or_else(|| std::env::var("COMPUTERNAME").ok().and_then(sanitize_string))
542 .or_else(|| {
543 std::fs::read_to_string("/etc/hostname")
544 .ok()
545 .and_then(sanitize_string)
546 })
547}
548
549#[cfg(feature = "opentelemetry")]
550fn detect_current_trace_context() -> (Option<String>, Option<String>) {
551 use opentelemetry::trace::TraceContextExt;
552 use tracing_opentelemetry::OpenTelemetrySpanExt;
553
554 let context = tracing::Span::current().context();
555 let span_ref = context.span();
556 let span_context = span_ref.span_context();
557 if !span_context.is_valid() {
558 return (None, None);
559 }
560
561 (
562 Some(span_context.trace_id().to_string()),
563 Some(span_context.span_id().to_string()),
564 )
565}
566
567#[cfg(not(feature = "opentelemetry"))]
568fn detect_current_trace_context() -> (Option<String>, Option<String>) {
569 (None, None)
570}
571
572#[derive(Debug, Clone)]
574pub struct Record<const CHANNEL_MASK: u8> {
575 pub id: RecordId,
576 pub kind: String,
577 pub common: Common,
578 pub observability: Option<ObservabilityPayload>,
579 pub audit: Option<AuditPayload>,
580 pub security: Option<SecurityPayload>,
581 pub operations: Option<OperationsPayload>,
582 pub attributes: Map<String, Value>,
583}
584
585impl<const CHANNEL_MASK: u8> Record<CHANNEL_MASK> {
586 pub fn new(kind: impl Into<String>) -> Self {
588 Self {
589 id: RecordId::new(),
590 kind: kind.into(),
591 common: Common::default(),
592 observability: None,
593 audit: None,
594 security: None,
595 operations: None,
596 attributes: Map::new(),
597 }
598 }
599
600 fn validate(&self) -> Result<(), RecordingError> {
601 validate_channel_mask::<CHANNEL_MASK>()?;
602
603 if self.kind.trim().is_empty() {
604 return Err(RecordingError::EmptyKind);
605 }
606
607 validate_channel_payload(
608 CHANNEL_MASK,
609 CHANNEL_OBSERVABILITY,
610 self.observability.is_some(),
611 "observability",
612 )?;
613 validate_channel_payload(CHANNEL_MASK, CHANNEL_AUDIT, self.audit.is_some(), "audit")?;
614 validate_channel_payload(
615 CHANNEL_MASK,
616 CHANNEL_SECURITY,
617 self.security.is_some(),
618 "security",
619 )?;
620 validate_channel_payload(
621 CHANNEL_MASK,
622 CHANNEL_OPERATIONS,
623 self.operations.is_some(),
624 "operations",
625 )?;
626
627 if let Some(payload) = &self.observability {
628 payload.validate()?;
629 }
630 if let Some(payload) = &self.audit {
631 payload.validate()?;
632 }
633 if let Some(payload) = &self.security {
634 payload.validate()?;
635 }
636 if let Some(payload) = &self.operations {
637 payload.validate()?;
638 }
639
640 Ok(())
641 }
642}
643
644#[derive(Debug, Error)]
646pub enum RecordingError {
647 #[error("recording channel mask cannot be empty")]
648 EmptyChannelMask,
649 #[error("recording channel mask has unsupported bits: {mask:#010b}")]
650 UnsupportedChannelMask { mask: u8 },
651 #[error("recording kind cannot be empty")]
652 EmptyKind,
653 #[error("channel '{channel}' was selected but payload is missing")]
654 MissingSelectedChannelPayload { channel: &'static str },
655 #[error("channel '{channel}' payload is provided but channel was not selected")]
656 UnexpectedChannelPayload { channel: &'static str },
657 #[error("channel '{channel}' field '{field}' cannot be empty")]
658 EmptyPayloadField {
659 channel: &'static str,
660 field: &'static str,
661 },
662 #[error("channel '{channel}' payload cannot be empty")]
663 EmptyPayload { channel: &'static str },
664 #[error("failed to serialize '{channel}' payload: {error}")]
665 SerializePayload {
666 channel: &'static str,
667 #[source]
668 error: serde_json::Error,
669 },
670}
671
672pub fn emit<const CHANNEL_MASK: u8>(record: Record<CHANNEL_MASK>) -> Result<(), RecordingError> {
674 emit_with_callsite(record, "unknown", 0, "unknown")
675}
676
677pub fn emit_with_callsite<const CHANNEL_MASK: u8>(
679 mut record: Record<CHANNEL_MASK>,
680 file: &'static str,
681 line: u32,
682 module_path: &'static str,
683) -> Result<(), RecordingError> {
684 let callsite = Callsite {
685 file,
686 line,
687 module_path,
688 };
689 record.common = enrich_common(record.common, &callsite);
690 record.validate()?;
691
692 let filters = get_channel_filters();
693 let level = record.common.level;
694 let common = record.common;
695 let Record {
696 id,
697 kind,
698 common: _,
699 observability,
700 audit,
701 security,
702 operations,
703 attributes,
704 } = record;
705
706 if let Some(payload) = observability
707 && should_emit_observability(&common, filters.observability)
708 {
709 let value = build_channel_record(
710 "observability",
711 &id,
712 &kind,
713 &common,
714 payload,
715 &attributes,
716 &callsite,
717 )?;
718 emit_channel(level, Channel::Observability, &value);
719 }
720
721 if let Some(payload) = audit
722 && should_emit_audit(&payload, filters.audit)
723 {
724 let value = build_channel_record(
725 "audit",
726 &id,
727 &kind,
728 &common,
729 payload,
730 &attributes,
731 &callsite,
732 )?;
733 emit_channel(level, Channel::Audit, &value);
734 }
735
736 if let Some(payload) = security
737 && should_emit_security(&payload, filters.security)
738 {
739 let value = build_channel_record(
740 "security",
741 &id,
742 &kind,
743 &common,
744 payload,
745 &attributes,
746 &callsite,
747 )?;
748 emit_channel(level, Channel::Security, &value);
749 }
750
751 if let Some(payload) = operations
752 && should_emit_operations(&common, filters.operations)
753 {
754 let value = build_channel_record(
755 "operations",
756 &id,
757 &kind,
758 &common,
759 payload,
760 &attributes,
761 &callsite,
762 )?;
763 emit_channel(level, Channel::Operations, &value);
764 }
765
766 Ok(())
767}
768
769#[derive(Debug, Clone, Copy)]
771enum Channel {
772 Observability,
773 Audit,
774 Security,
775 Operations,
776}
777
778#[allow(clippy::disallowed_macros)]
780fn emit_channel(level: RecordLevel, channel: Channel, record: &Value) {
781 match channel {
782 Channel::Observability => match level {
783 RecordLevel::Trace => {
784 tracing::event!(target: "actrix::observability", tracing::Level::TRACE, recording = %record)
785 }
786 RecordLevel::Debug => {
787 tracing::event!(target: "actrix::observability", tracing::Level::DEBUG, recording = %record)
788 }
789 RecordLevel::Info => {
790 tracing::event!(target: "actrix::observability", tracing::Level::INFO, recording = %record)
791 }
792 RecordLevel::Warn => {
793 tracing::event!(target: "actrix::observability", tracing::Level::WARN, recording = %record)
794 }
795 RecordLevel::Error => {
796 tracing::event!(target: "actrix::observability", tracing::Level::ERROR, recording = %record)
797 }
798 },
799 Channel::Audit => match level {
800 RecordLevel::Trace => {
801 tracing::event!(target: "actrix::audit", tracing::Level::TRACE, recording = %record)
802 }
803 RecordLevel::Debug => {
804 tracing::event!(target: "actrix::audit", tracing::Level::DEBUG, recording = %record)
805 }
806 RecordLevel::Info => {
807 tracing::event!(target: "actrix::audit", tracing::Level::INFO, recording = %record)
808 }
809 RecordLevel::Warn => {
810 tracing::event!(target: "actrix::audit", tracing::Level::WARN, recording = %record)
811 }
812 RecordLevel::Error => {
813 tracing::event!(target: "actrix::audit", tracing::Level::ERROR, recording = %record)
814 }
815 },
816 Channel::Security => match level {
817 RecordLevel::Trace => {
818 tracing::event!(target: "actrix::security", tracing::Level::TRACE, recording = %record)
819 }
820 RecordLevel::Debug => {
821 tracing::event!(target: "actrix::security", tracing::Level::DEBUG, recording = %record)
822 }
823 RecordLevel::Info => {
824 tracing::event!(target: "actrix::security", tracing::Level::INFO, recording = %record)
825 }
826 RecordLevel::Warn => {
827 tracing::event!(target: "actrix::security", tracing::Level::WARN, recording = %record)
828 }
829 RecordLevel::Error => {
830 tracing::event!(target: "actrix::security", tracing::Level::ERROR, recording = %record)
831 }
832 },
833 Channel::Operations => match level {
834 RecordLevel::Trace => {
835 tracing::event!(target: "actrix::operations", tracing::Level::TRACE, recording = %record)
836 }
837 RecordLevel::Debug => {
838 tracing::event!(target: "actrix::operations", tracing::Level::DEBUG, recording = %record)
839 }
840 RecordLevel::Info => {
841 tracing::event!(target: "actrix::operations", tracing::Level::INFO, recording = %record)
842 }
843 RecordLevel::Warn => {
844 tracing::event!(target: "actrix::operations", tracing::Level::WARN, recording = %record)
845 }
846 RecordLevel::Error => {
847 tracing::event!(target: "actrix::operations", tracing::Level::ERROR, recording = %record)
848 }
849 },
850 }
851}
852
853fn validate_required_non_empty(
854 channel: &'static str,
855 field: &'static str,
856 value: &str,
857) -> Result<(), RecordingError> {
858 if value.trim().is_empty() {
859 return Err(RecordingError::EmptyPayloadField { channel, field });
860 }
861
862 Ok(())
863}
864
865fn validate_optional_non_empty(
866 channel: &'static str,
867 field: &'static str,
868 value: &Option<String>,
869) -> Result<(), RecordingError> {
870 if let Some(v) = value
871 && v.trim().is_empty()
872 {
873 return Err(RecordingError::EmptyPayloadField { channel, field });
874 }
875
876 Ok(())
877}
878
879const fn validate_channel_mask<const CHANNEL_MASK: u8>() -> Result<(), RecordingError> {
880 if CHANNEL_MASK == 0 {
881 return Err(RecordingError::EmptyChannelMask);
882 }
883
884 if CHANNEL_MASK & !CHANNEL_MASK_ALL != 0 {
885 return Err(RecordingError::UnsupportedChannelMask { mask: CHANNEL_MASK });
886 }
887
888 Ok(())
889}
890
891fn validate_channel_payload(
892 selected_mask: u8,
893 channel_mask: u8,
894 has_payload: bool,
895 channel_name: &'static str,
896) -> Result<(), RecordingError> {
897 let selected = selected_mask & channel_mask != 0;
898
899 if selected && !has_payload {
900 return Err(RecordingError::MissingSelectedChannelPayload {
901 channel: channel_name,
902 });
903 }
904
905 if !selected && has_payload {
906 return Err(RecordingError::UnexpectedChannelPayload {
907 channel: channel_name,
908 });
909 }
910
911 Ok(())
912}
913
914fn build_channel_record<P: Serialize>(
915 channel_name: &'static str,
916 record_id: &RecordId,
917 kind: &str,
918 common: &Common,
919 payload: P,
920 attributes: &Map<String, Value>,
921 callsite: &Callsite,
922) -> Result<Value, RecordingError> {
923 let payload_value =
924 serde_json::to_value(payload).map_err(|error| RecordingError::SerializePayload {
925 channel: channel_name,
926 error,
927 })?;
928
929 Ok(json!({
930 "recording_id": record_id,
931 "kind": kind,
932 "channel": channel_name,
933 "timestamp": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
934 "common": common,
935 "callsite": {
936 "file": callsite.file,
937 "line": callsite.line,
938 "module": callsite.module_path,
939 },
940 "process": {
941 "pid": std::process::id(),
942 "thread": format!("{:?}", std::thread::current().id()),
943 },
944 "attributes": attributes,
945 "payload": payload_value,
946 }))
947}
948
949#[macro_export]
954macro_rules! recording_log {
955 ($record:expr $(,)?) => {{ $crate::recording::emit_with_callsite($record, file!(), line!(), module_path!()) }};
956}
957
958pub use crate::recording_log as log;
960
961#[macro_export]
966macro_rules! recording_observability_log {
967 ($level:expr, $($arg:tt)+) => {{
968 let _ = $crate::recording::emit_with_callsite(
969 $crate::recording::Record::<{ $crate::recording::CHANNEL_OBSERVABILITY }> {
970 id: $crate::recording::RecordId::new(),
971 kind: "runtime.log".to_string(),
972 common: $crate::recording::Common {
973 level: $level,
974 ..Default::default()
975 },
976 observability: Some($crate::recording::ObservabilityPayload {
977 summary: Some(format!($($arg)+)),
978 ..Default::default()
979 }),
980 audit: None,
981 security: None,
982 operations: None,
983 attributes: Default::default(),
984 },
985 file!(),
986 line!(),
987 module_path!(),
988 );
989 }};
990}
991
992#[macro_export]
993macro_rules! recording_trace {
994 ($($arg:tt)+) => {{
995 $crate::recording_observability_log!($crate::recording::RecordLevel::Trace, $($arg)+);
996 }};
997}
998
999#[macro_export]
1000macro_rules! recording_debug {
1001 ($($arg:tt)+) => {{
1002 $crate::recording_observability_log!($crate::recording::RecordLevel::Debug, $($arg)+);
1003 }};
1004}
1005
1006#[macro_export]
1007macro_rules! recording_info {
1008 ($($arg:tt)+) => {{
1009 $crate::recording_observability_log!($crate::recording::RecordLevel::Info, $($arg)+);
1010 }};
1011}
1012
1013#[macro_export]
1014macro_rules! recording_warn {
1015 ($($arg:tt)+) => {{
1016 $crate::recording_observability_log!($crate::recording::RecordLevel::Warn, $($arg)+);
1017 }};
1018}
1019
1020#[macro_export]
1021macro_rules! recording_error {
1022 ($($arg:tt)+) => {{
1023 $crate::recording_observability_log!($crate::recording::RecordLevel::Error, $($arg)+);
1024 }};
1025}
1026
1027pub use crate::recording_debug as debug;
1029pub use crate::recording_error as error;
1030pub use crate::recording_info as info;
1031pub use crate::recording_trace as trace;
1032pub use crate::recording_warn as warn;
1033
1034#[cfg(test)]
1035mod tests {
1036 use super::*;
1037
1038 fn base_record<const CHANNEL_MASK: u8>() -> Record<CHANNEL_MASK> {
1039 Record::new("test.recording")
1040 }
1041
1042 fn make_callsite(module_path: &'static str) -> Callsite {
1043 Callsite {
1044 file: "test.rs",
1045 line: 42,
1046 module_path,
1047 }
1048 }
1049
1050 #[test]
1051 fn enrich_common_should_preserve_explicit_fields() {
1052 let common = Common {
1053 actor: Some("actor:1".to_string()),
1054 outcome: Outcome::Success,
1055 level: RecordLevel::Info,
1056 trace_id: Some("trace-explicit".to_string()),
1057 span_id: Some("span-explicit".to_string()),
1058 request_id: Some("request-1".to_string()),
1059 source_service: Some("service-explicit".to_string()),
1060 source_node: Some("node-explicit".to_string()),
1061 };
1062
1063 let enriched = enrich_common(common, &make_callsite("actrix::service::manager"));
1064
1065 assert_eq!(enriched.actor.as_deref(), Some("actor:1"));
1066 assert_eq!(enriched.trace_id.as_deref(), Some("trace-explicit"));
1067 assert_eq!(enriched.span_id.as_deref(), Some("span-explicit"));
1068 assert_eq!(enriched.request_id.as_deref(), Some("request-1"));
1069 assert_eq!(enriched.source_service.as_deref(), Some("service-explicit"));
1070 assert_eq!(enriched.source_node.as_deref(), Some("node-explicit"));
1071 }
1072
1073 #[test]
1074 fn enrich_common_should_fill_source_service_from_module_path() {
1075 let common = Common::default();
1076 let enriched = enrich_common(common, &make_callsite("actrix::service::manager"));
1077
1078 assert_eq!(enriched.source_service.as_deref(), Some("actrix"));
1079 }
1080
1081 #[test]
1082 fn enrich_common_should_treat_blank_explicit_values_as_missing() {
1083 let common = Common {
1084 source_service: Some(" ".to_string()),
1085 request_id: Some(" ".to_string()),
1086 ..Default::default()
1087 };
1088
1089 let enriched = enrich_common(common, &make_callsite("actrix::service::manager"));
1090
1091 assert_eq!(enriched.source_service.as_deref(), Some("actrix"));
1092 assert!(enriched.request_id.is_none());
1093 }
1094
1095 #[test]
1096 fn emit_observability_record_should_succeed() {
1097 let mut record = base_record::<{ CHANNEL_OBSERVABILITY }>();
1098 record.observability = Some(ObservabilityPayload {
1099 summary: Some("ready".to_string()),
1100 component: Some("health.check".to_string()),
1101 operation: Some("http.health".to_string()),
1102 protocol: Some(Protocol::Http),
1103 route: Some("/health".to_string()),
1104 method: Some("GET".to_string()),
1105 ..Default::default()
1106 });
1107
1108 assert!(emit(record).is_ok());
1109 }
1110
1111 #[test]
1112 fn emit_should_fail_when_selected_channel_payload_is_missing() {
1113 let record = base_record::<{ CHANNEL_AUDIT }>();
1114 let error = emit(record).expect_err("audit payload should be required");
1115
1116 assert!(matches!(
1117 error,
1118 RecordingError::MissingSelectedChannelPayload { channel: "audit" }
1119 ));
1120 }
1121
1122 #[test]
1123 fn emit_should_fail_when_unselected_channel_payload_is_present() {
1124 let mut record = base_record::<{ CHANNEL_OBSERVABILITY }>();
1125 record.observability = Some(ObservabilityPayload {
1126 summary: Some("http route mounted".to_string()),
1127 ..Default::default()
1128 });
1129 record.audit = Some(AuditPayload {
1130 action: "config.update".to_string(),
1131 resource: "bind.http.port".to_string(),
1132 resource_id: None,
1133 reason: None,
1134 remote_addr: None,
1135 session_id: None,
1136 before: Some(json!("8080")),
1137 after: Some(json!("8443")),
1138 });
1139
1140 let error = emit(record).expect_err("audit payload should not be accepted");
1141
1142 assert!(matches!(
1143 error,
1144 RecordingError::UnexpectedChannelPayload { channel: "audit" }
1145 ));
1146 }
1147
1148 #[test]
1149 fn emit_should_fail_on_empty_channel_mask() {
1150 let record = base_record::<0>();
1151 let error = emit(record).expect_err("empty mask should fail");
1152
1153 assert!(matches!(error, RecordingError::EmptyChannelMask));
1154 }
1155
1156 #[test]
1157 fn emit_should_fail_when_required_payload_field_is_empty() {
1158 let mut record = base_record::<{ CHANNEL_AUDIT }>();
1159 record.audit = Some(AuditPayload {
1160 action: String::new(),
1161 resource: "service.binding".to_string(),
1162 resource_id: None,
1163 reason: None,
1164 remote_addr: None,
1165 session_id: None,
1166 before: None,
1167 after: None,
1168 });
1169
1170 let error = emit(record).expect_err("empty action should fail validation");
1171
1172 assert!(matches!(
1173 error,
1174 RecordingError::EmptyPayloadField {
1175 channel: "audit",
1176 field: "action"
1177 }
1178 ));
1179 }
1180
1181 #[test]
1182 fn emit_should_fail_when_optional_payload_field_is_blank() {
1183 let mut record = base_record::<{ CHANNEL_SECURITY }>();
1184 record.security = Some(SecurityPayload {
1185 control: "rate_limit.connection".to_string(),
1186 severity: SecuritySeverity::High,
1187 category: Some(String::new()),
1188 subject: None,
1189 source_addr: None,
1190 destination_addr: None,
1191 evidence: None,
1192 });
1193
1194 let error = emit(record).expect_err("blank optional field should fail validation");
1195
1196 assert!(matches!(
1197 error,
1198 RecordingError::EmptyPayloadField {
1199 channel: "security",
1200 field: "category"
1201 }
1202 ));
1203 }
1204
1205 #[test]
1206 fn emit_should_fail_when_observability_payload_is_empty() {
1207 let mut record = base_record::<{ CHANNEL_OBSERVABILITY }>();
1208 record.observability = Some(ObservabilityPayload::default());
1209
1210 let error = emit(record).expect_err("empty observability payload should fail validation");
1211
1212 assert!(matches!(
1213 error,
1214 RecordingError::EmptyPayload {
1215 channel: "observability"
1216 }
1217 ));
1218 }
1219
1220 #[test]
1223 fn parse_observability_filter_values() {
1224 assert_eq!(parse_observability_filter("off"), ObservabilityFilter::Off);
1225 assert_eq!(
1226 parse_observability_filter("digest"),
1227 ObservabilityFilter::Digest
1228 );
1229 assert_eq!(
1230 parse_observability_filter("detailed"),
1231 ObservabilityFilter::Detailed
1232 );
1233 assert_eq!(
1234 parse_observability_filter("full"),
1235 ObservabilityFilter::Full
1236 );
1237 assert_eq!(
1238 parse_observability_filter("bogus"),
1239 ObservabilityFilter::Digest
1240 );
1241 }
1242
1243 #[test]
1244 fn parse_audit_filter_values() {
1245 assert_eq!(parse_audit_filter("off"), AuditFilter::Off);
1246 assert_eq!(parse_audit_filter("mutations"), AuditFilter::Mutations);
1247 assert_eq!(parse_audit_filter("all"), AuditFilter::All);
1248 assert_eq!(parse_audit_filter("bogus"), AuditFilter::Mutations);
1249 }
1250
1251 #[test]
1252 fn parse_security_filter_values() {
1253 assert_eq!(parse_security_filter("off"), SecurityFilter::Off);
1254 assert_eq!(parse_security_filter("critical"), SecurityFilter::Critical);
1255 assert_eq!(parse_security_filter("high"), SecurityFilter::High);
1256 assert_eq!(parse_security_filter("medium"), SecurityFilter::Medium);
1257 assert_eq!(parse_security_filter("all"), SecurityFilter::All);
1258 assert_eq!(parse_security_filter("bogus"), SecurityFilter::All);
1259 }
1260
1261 #[test]
1262 fn parse_operations_filter_values() {
1263 assert_eq!(parse_operations_filter("off"), OperationsFilter::Off);
1264 assert_eq!(
1265 parse_operations_filter("lifecycle"),
1266 OperationsFilter::Lifecycle
1267 );
1268 assert_eq!(
1269 parse_operations_filter("detailed"),
1270 OperationsFilter::Detailed
1271 );
1272 assert_eq!(
1273 parse_operations_filter("bogus"),
1274 OperationsFilter::Lifecycle
1275 );
1276 }
1277
1278 #[test]
1281 fn observability_gate_digest_passes_info_blocks_debug() {
1282 let info = Common {
1283 level: RecordLevel::Info,
1284 ..Default::default()
1285 };
1286 let debug = Common {
1287 level: RecordLevel::Debug,
1288 ..Default::default()
1289 };
1290 let trace = Common {
1291 level: RecordLevel::Trace,
1292 ..Default::default()
1293 };
1294 assert!(should_emit_observability(
1295 &info,
1296 ObservabilityFilter::Digest
1297 ));
1298 assert!(!should_emit_observability(
1299 &debug,
1300 ObservabilityFilter::Digest
1301 ));
1302 assert!(!should_emit_observability(
1303 &trace,
1304 ObservabilityFilter::Digest
1305 ));
1306 }
1307
1308 #[test]
1309 fn observability_gate_detailed_passes_debug_blocks_trace() {
1310 let debug = Common {
1311 level: RecordLevel::Debug,
1312 ..Default::default()
1313 };
1314 let trace = Common {
1315 level: RecordLevel::Trace,
1316 ..Default::default()
1317 };
1318 assert!(should_emit_observability(
1319 &debug,
1320 ObservabilityFilter::Detailed
1321 ));
1322 assert!(!should_emit_observability(
1323 &trace,
1324 ObservabilityFilter::Detailed
1325 ));
1326 }
1327
1328 #[test]
1329 fn observability_gate_full_passes_all() {
1330 let trace = Common {
1331 level: RecordLevel::Trace,
1332 ..Default::default()
1333 };
1334 assert!(should_emit_observability(&trace, ObservabilityFilter::Full));
1335 }
1336
1337 #[test]
1338 fn observability_gate_off_blocks_all() {
1339 let error = Common {
1340 level: RecordLevel::Error,
1341 ..Default::default()
1342 };
1343 assert!(!should_emit_observability(&error, ObservabilityFilter::Off));
1344 }
1345
1346 #[test]
1347 fn audit_gate_mutations_filters_reads() {
1348 let write_payload = AuditPayload {
1349 action: "config.update".to_string(),
1350 resource: "bind".to_string(),
1351 resource_id: None,
1352 reason: None,
1353 remote_addr: None,
1354 session_id: None,
1355 before: None,
1356 after: None,
1357 };
1358 let read_payload = AuditPayload {
1359 action: "read.config".to_string(),
1360 resource: "bind".to_string(),
1361 resource_id: None,
1362 reason: None,
1363 remote_addr: None,
1364 session_id: None,
1365 before: None,
1366 after: None,
1367 };
1368 let list_payload = AuditPayload {
1369 action: "list.realms".to_string(),
1370 resource: "realm".to_string(),
1371 resource_id: None,
1372 reason: None,
1373 remote_addr: None,
1374 session_id: None,
1375 before: None,
1376 after: None,
1377 };
1378 assert!(should_emit_audit(&write_payload, AuditFilter::Mutations));
1379 assert!(!should_emit_audit(&read_payload, AuditFilter::Mutations));
1380 assert!(!should_emit_audit(&list_payload, AuditFilter::Mutations));
1381 assert!(should_emit_audit(&read_payload, AuditFilter::All));
1382 }
1383
1384 #[test]
1385 fn security_gate_severity_threshold() {
1386 let low = SecurityPayload {
1387 control: "test".to_string(),
1388 severity: SecuritySeverity::Low,
1389 category: None,
1390 subject: None,
1391 source_addr: None,
1392 destination_addr: None,
1393 evidence: None,
1394 };
1395 let high = SecurityPayload {
1396 control: "test".to_string(),
1397 severity: SecuritySeverity::High,
1398 category: None,
1399 subject: None,
1400 source_addr: None,
1401 destination_addr: None,
1402 evidence: None,
1403 };
1404 let critical = SecurityPayload {
1405 control: "test".to_string(),
1406 severity: SecuritySeverity::Critical,
1407 category: None,
1408 subject: None,
1409 source_addr: None,
1410 destination_addr: None,
1411 evidence: None,
1412 };
1413 assert!(!should_emit_security(&low, SecurityFilter::High));
1414 assert!(should_emit_security(&high, SecurityFilter::High));
1415 assert!(should_emit_security(&critical, SecurityFilter::High));
1416 assert!(should_emit_security(&critical, SecurityFilter::Critical));
1417 assert!(!should_emit_security(&high, SecurityFilter::Critical));
1418 assert!(should_emit_security(&low, SecurityFilter::All));
1419 }
1420
1421 #[test]
1422 fn operations_gate_lifecycle_passes_info() {
1423 let info = Common {
1424 level: RecordLevel::Info,
1425 ..Default::default()
1426 };
1427 let debug = Common {
1428 level: RecordLevel::Debug,
1429 ..Default::default()
1430 };
1431 assert!(should_emit_operations(&info, OperationsFilter::Lifecycle));
1432 assert!(!should_emit_operations(&debug, OperationsFilter::Lifecycle));
1433 assert!(should_emit_operations(&debug, OperationsFilter::Detailed));
1434 }
1435}