1use std::{error::Error, fmt, time::Instant};
2
3use saddle_core::{CallContext, ErrorKind, SaddleError};
4use serde_json::{Value, json};
5
6use crate::{EventLevel, Observer, OutputStage, logger::LogRecord};
7
8const MAX_IDENTITY_BYTES: usize = 256;
9
10#[derive(Clone, Debug, Eq, PartialEq)]
11pub enum ChainFieldError {
12 Empty,
13 TooLong,
14 ControlCharacter,
15 UnsafeAuthority,
16 ZeroAttempt,
17}
18
19impl fmt::Display for ChainFieldError {
20 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21 f.write_str(match self {
22 Self::Empty => "observability identity must not be empty",
23 Self::TooLong => "observability identity exceeds 256 bytes",
24 Self::ControlCharacter => "observability identity contains a control character",
25 Self::UnsafeAuthority => "outbound authority must be a credential-free target label",
26 Self::ZeroAttempt => "attempt must be greater than zero",
27 })
28 }
29}
30
31impl Error for ChainFieldError {}
32
33macro_rules! safe_identity {
34 ($name:ident) => {
35 #[derive(Clone, Debug, Eq, PartialEq)]
36 pub struct $name(String);
37
38 impl $name {
39 pub fn new(value: impl Into<String>) -> Result<Self, ChainFieldError> {
40 validate_identity(value.into()).map(Self)
41 }
42
43 pub fn as_str(&self) -> &str {
44 &self.0
45 }
46 }
47 };
48}
49
50safe_identity!(RequestIdentity);
51safe_identity!(RouteIdentity);
52safe_identity!(BottleneckIdentity);
53safe_identity!(RejectReason);
54
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct OutboundAuthority(String);
57
58impl OutboundAuthority {
59 pub fn new(value: impl Into<String>) -> Result<Self, ChainFieldError> {
60 let value = validate_identity(value.into())?;
61 if value.contains(['@', '?', '#', '/', '\\']) || value.contains("://") {
62 return Err(ChainFieldError::UnsafeAuthority);
63 }
64 Ok(Self(value))
65 }
66}
67
68fn validate_identity(value: String) -> Result<String, ChainFieldError> {
69 if value.is_empty() {
70 return Err(ChainFieldError::Empty);
71 }
72 if value.len() > MAX_IDENTITY_BYTES {
73 return Err(ChainFieldError::TooLong);
74 }
75 if value.chars().any(char::is_control) {
76 return Err(ChainFieldError::ControlCharacter);
77 }
78 Ok(value)
79}
80
81#[derive(Clone, Copy, Debug, Eq, PartialEq)]
82pub enum Stage {
83 Ingress,
84 Admission,
85 Handler,
86 Database,
87 ProfuseContract,
88 Response,
89 ResourceFinalization,
90}
91
92impl Stage {
93 const fn as_str(self) -> &'static str {
94 match self {
95 Self::Ingress => "ingress",
96 Self::Admission => "admission",
97 Self::Handler => "handler",
98 Self::Database => "database",
99 Self::ProfuseContract => "profuse_contract",
100 Self::Response => "response",
101 Self::ResourceFinalization => "resource_finalization",
102 }
103 }
104}
105
106#[derive(Clone, Copy, Debug, Eq, PartialEq)]
107pub enum StageOutcome {
108 Success,
109 Failure,
110 Rejected,
111 Cancelled,
112}
113
114#[derive(Clone, Copy, Debug, Eq, PartialEq)]
116pub enum TransactionOutcome {
117 Committed,
119 Rejected,
121 Unknown,
123}
124
125impl TransactionOutcome {
126 const fn as_str(self) -> &'static str {
127 match self {
128 Self::Committed => "Committed",
129 Self::Rejected => "Rejected",
130 Self::Unknown => "Unknown",
131 }
132 }
133}
134
135pub type TransactionTerminalObservation =
138 saddle_core::DbScopeTerminalObservation<(Observer, CallContext, EventContext)>;
139
140impl StageOutcome {
141 const fn as_str(self) -> &'static str {
142 match self {
143 Self::Success => "success",
144 Self::Failure => "failure",
145 Self::Rejected => "rejected",
146 Self::Cancelled => "cancelled",
147 }
148 }
149}
150
151#[derive(Clone, Debug, Eq, PartialEq)]
152pub struct EventContext {
153 request: RequestIdentity,
154 route: RouteIdentity,
155 attempt: u32,
156}
157
158impl EventContext {
159 pub(crate) fn diagnostic_attempt(&self) -> u32 {
160 self.attempt
161 }
162 pub(crate) fn diagnostic_request(&self) -> &str {
163 self.request.as_str()
164 }
165 pub(crate) fn diagnostic_route(&self) -> &str {
166 self.route.as_str()
167 }
168 pub fn new(
169 request: RequestIdentity,
170 route: RouteIdentity,
171 attempt: u32,
172 ) -> Result<Self, ChainFieldError> {
173 if attempt == 0 {
174 return Err(ChainFieldError::ZeroAttempt);
175 }
176 Ok(Self {
177 request,
178 route,
179 attempt,
180 })
181 }
182}
183
184pub struct ActiveStage {
185 observer: Observer,
186 context: CallContext,
187 parent_span: String,
188 stage: Stage,
189 request: RequestIdentity,
190 route: RouteIdentity,
191 attempt: u32,
192 started_at: Instant,
193 finished: bool,
194}
195
196impl Observer {
197 pub fn record_transaction_terminal(
209 observation: TransactionTerminalObservation,
210 outcome: TransactionOutcome,
211 ) {
212 let ((observer, context, event_context), scope) = observation.into_log_parts();
213 let mut record = base_record(
214 &context,
215 if outcome == TransactionOutcome::Committed {
216 EventLevel::Info
217 } else {
218 EventLevel::Error
219 },
220 "database_transaction_terminal",
221 "database",
222 );
223 add_event_context(&mut record, &event_context);
224 let Ok(Value::Object(fields)) = serde_json::to_value(scope) else {
227 return;
228 };
229 record.data.extend(fields);
230 record.data.insert(
231 "transaction_outcome".into(),
232 Value::String(outcome.as_str().into()),
233 );
234 record
235 .data
236 .insert("outcome".into(), Value::String(outcome.as_str().into()));
237 observer.emit(record);
238 }
239
240 pub fn record_lifecycle_timeout(
241 &self,
242 application: &str,
243 stage: LifecycleTimeoutStage,
244 elapsed_ms: u64,
245 ) {
246 let Ok((call, _)) = self.start_external_call_checked(
247 application,
248 "runtime",
249 "lifecycle",
250 stage.as_str(),
251 None,
252 ) else {
253 return;
254 };
255 let mut record = base_record(
256 call.context(),
257 EventLevel::Error,
258 "framework.lifecycle.timeout",
259 "lifecycle",
260 );
261 record
262 .data
263 .insert("timeout_stage".into(), Value::String(stage.as_str().into()));
264 record.data.insert("elapsed_ms".into(), json!(elapsed_ms));
265 record
266 .data
267 .insert("outcome".into(), Value::String("timeout".into()));
268 self.emit(record);
269 call.fail(&SaddleError::new(
270 ErrorKind::Infrastructure,
271 "runtime.lifecycle_timeout",
272 "managed lifecycle deadline elapsed",
273 ));
274 }
275
276 pub fn start_stage(
277 &self,
278 parent: &CallContext,
279 stage: Stage,
280 event_context: EventContext,
281 ) -> ActiveStage {
282 let context = CallContext::new(
283 parent.application().clone(),
284 parent.module().clone(),
285 parent.service().clone(),
286 parent.operation().clone(),
287 parent.trace_id(),
288 self.new_span_id(),
289 )
290 .with_trace_correlation_id(parent.trace_correlation_id().clone())
291 .with_rpc_correlation_id(parent.rpc_correlation_id().cloned());
292 let active = ActiveStage {
293 observer: self.clone(),
294 context,
295 parent_span: parent.span_id().to_string(),
296 stage,
297 request: event_context.request,
298 route: event_context.route,
299 attempt: event_context.attempt,
300 started_at: Instant::now(),
301 finished: false,
302 };
303 active.emit_started();
304 active
305 }
306
307 pub fn record_capacity(
308 &self,
309 context: &CallContext,
310 event_context: &EventContext,
311 value: CapacityObservation,
312 ) {
313 self.inner
314 .metrics
315 .capacity(value.dimension, value.used, value.reject_reason.is_some());
316 let mut record = base_record(context, EventLevel::Info, "framework.capacity", "admission");
317 add_event_context(&mut record, event_context);
318 if let Some(dimension) = value.dimension {
319 record.data.insert(
320 "capacity_dimension".into(),
321 Value::String(dimension.as_str().into()),
322 );
323 }
324 record.data.insert("budget".into(), json!(value.budget));
325 record.data.insert("limit".into(), json!(value.limit));
326 record.data.insert("used".into(), json!(value.used));
327 record
328 .data
329 .insert("elapsed_ms".into(), json!(value.elapsed_ms));
330 record
331 .data
332 .insert("bottleneck".into(), Value::String(value.bottleneck.0));
333 record.data.insert(
334 "outcome".into(),
335 Value::String(
336 if value.reject_reason.is_some() {
337 "rejected"
338 } else {
339 "accepted"
340 }
341 .into(),
342 ),
343 );
344 if let Some(reason) = value.reject_reason {
345 record
346 .data
347 .insert("reject_reason".into(), Value::String(reason.0));
348 }
349 self.emit(record);
350 }
351
352 pub fn observe_database_disposition(&self, disposition: DatabaseDisposition) {
356 self.inner.metrics.database(disposition);
357 }
358
359 pub fn record_database_disposition(
360 &self,
361 context: &CallContext,
362 event_context: &EventContext,
363 disposition: DatabaseDisposition,
364 ) {
365 self.observe_database_disposition(disposition);
366 let mut record = base_record(
367 context,
368 EventLevel::Info,
369 "framework.database.disposition",
370 "database",
371 );
372 add_event_context(&mut record, event_context);
373 record.data.insert(
374 "db_disposition".into(),
375 Value::String(disposition.as_str().into()),
376 );
377 record
378 .data
379 .insert("outcome".into(), Value::String(disposition.as_str().into()));
380 self.emit(record);
381 }
382
383 pub fn record_resource_finalization(
384 &self,
385 context: &CallContext,
386 event_context: &EventContext,
387 disposition: DatabaseDisposition,
388 elapsed_ms: u64,
389 ) {
390 let mut record = base_record(
391 context,
392 EventLevel::Info,
393 "framework.resource.finalized",
394 "resource_finalization",
395 );
396 add_event_context(&mut record, event_context);
397 record.data.insert("elapsed_ms".into(), json!(elapsed_ms));
398 record
399 .data
400 .insert("credit".into(), Value::String("released".into()));
401 record.data.insert(
402 "db_disposition".into(),
403 Value::String(disposition.as_str().into()),
404 );
405 record
406 .data
407 .insert("outcome".into(), Value::String("success".into()));
408 self.emit(record);
409 }
410
411 pub fn record_outbound(
412 &self,
413 context: &CallContext,
414 event_context: &EventContext,
415 observation: OutboundObservation,
416 ) {
417 self.inner.metrics.outbound(observation.result);
418 let mut record = base_record(
419 context,
420 EventLevel::Info,
421 "framework.outbound",
422 "profuse_contract",
423 );
424 add_event_context(&mut record, event_context);
425 record
426 .data
427 .insert("zone".into(), Value::String(observation.zone.0));
428 record
429 .data
430 .insert("authority".into(), Value::String(observation.authority.0));
431 record.data.insert(
432 "outbound_result".into(),
433 Value::String(observation.result.as_str().into()),
434 );
435 record.data.insert(
436 "outcome".into(),
437 Value::String(observation.result.as_str().into()),
438 );
439 self.emit(record);
440 }
441
442 pub fn record_lifecycle(
443 &self,
444 context: &CallContext,
445 event_context: &EventContext,
446 state: LifecycleState,
447 health: Health,
448 ) {
449 self.inner.metrics.lifecycle(state, health);
450 let mut record = base_record(
451 context,
452 if health == Health::Healthy {
453 EventLevel::Info
454 } else {
455 EventLevel::Error
456 },
457 "framework.lifecycle",
458 "resource_finalization",
459 );
460 add_event_context(&mut record, event_context);
461 record
462 .data
463 .insert("lifecycle".into(), Value::String(state.as_str().into()));
464 record
465 .data
466 .insert("health".into(), Value::String(health.as_str().into()));
467 record
468 .data
469 .insert("outcome".into(), Value::String(health.as_str().into()));
470 self.emit(record);
471 }
472
473 pub fn record_logger_health(
474 &self,
475 context: &CallContext,
476 event_context: &EventContext,
477 dropped: u64,
478 failure: Option<OutputStage>,
479 ) {
480 self.inner.metrics.logger_output(failure.is_some());
481 let mut record = base_record(
482 context,
483 if failure.is_some() || dropped > 0 {
484 EventLevel::Error
485 } else {
486 EventLevel::Info
487 },
488 "framework.logger.health",
489 "logger",
490 );
491 add_event_context(&mut record, event_context);
492 record.data.insert("logger_dropped".into(), json!(dropped));
493 record.data.insert(
494 "logger_health".into(),
495 Value::String(
496 if failure.is_some() {
497 "output_failed"
498 } else {
499 "healthy"
500 }
501 .into(),
502 ),
503 );
504 record.data.insert(
505 "outcome".into(),
506 Value::String(
507 if failure.is_some() {
508 "failure"
509 } else {
510 "success"
511 }
512 .into(),
513 ),
514 );
515 if let Some(stage) = failure {
516 record.data.insert(
517 "output_failed".into(),
518 Value::String(format!("{stage:?}").to_ascii_lowercase()),
519 );
520 }
521 self.emit(record);
522 }
523}
524
525#[derive(Clone, Copy, Debug, Eq, PartialEq)]
526pub enum LifecycleTimeoutStage {
527 ComponentStart,
528 RequestDrain,
529 ComponentShutdown,
530 PostDriverFinalization,
531}
532
533impl LifecycleTimeoutStage {
534 const fn as_str(self) -> &'static str {
535 match self {
536 Self::ComponentStart => "component_start",
537 Self::RequestDrain => "request_drain",
538 Self::ComponentShutdown => "component_shutdown",
539 Self::PostDriverFinalization => "post_driver_finalization",
540 }
541 }
542}
543
544impl ActiveStage {
545 #[allow(clippy::result_large_err)] pub fn finish_nonfailure_bounded(
550 mut self,
551 output: Option<&crate::EmergencyDiagnosticHandle>,
552 axes: &saddle_core::DiagnosticOutcomeAxes,
553 ) -> Result<crate::DiagnosticSubmission, Self> {
554 if !matches!(
555 axes.operation,
556 saddle_core::OperationOutcome::Succeeded | saddle_core::OperationOutcome::Rejected
557 ) {
558 return Err(self);
559 }
560 let elapsed = self.finish_bounded_metrics(axes);
561 Ok(
562 output.map_or(crate::DiagnosticSubmission::OutputUnavailable, |output| {
563 output.submit_boundary_parts(
564 None,
565 axes,
566 Some((&self.context, self.request.as_str(), self.route.as_str())),
567 Some((self.stage.as_str(), elapsed)),
568 )
569 }),
570 )
571 }
572
573 pub fn finish_request_failure<D>(
576 mut self,
577 output: Option<&crate::EmergencyDiagnosticHandle>,
578 axes: &saddle_core::DiagnosticOutcomeAxes,
579 reference: &crate::RequestBoundaryReference<D>,
580 ) -> crate::BoundaryDiagnosticDelivery {
581 let elapsed = self.finish_bounded_metrics(axes);
582 reference.record_stage(output, axes, self.stage.as_str(), elapsed)
583 }
584
585 fn finish_bounded_metrics(&mut self, axes: &saddle_core::DiagnosticOutcomeAxes) -> u64 {
586 self.finished = true;
587 let elapsed = u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
588 use saddle_core::OperationOutcome;
589 let metric = match axes.operation {
590 OperationOutcome::Succeeded => Some(StageOutcome::Success),
591 OperationOutcome::Rejected => Some(StageOutcome::Rejected),
592 OperationOutcome::Failed | OperationOutcome::Panicked => Some(StageOutcome::Failure),
593 OperationOutcome::Cancelled | OperationOutcome::TimedOut => {
594 Some(StageOutcome::Cancelled)
595 }
596 OperationOutcome::Unknown => None,
597 };
598 if let Some(metric) = metric {
599 self.observer
600 .inner
601 .metrics
602 .stage_finished(self.stage, metric, elapsed);
603 }
604 elapsed
605 }
606 pub fn finish_bounded(
609 self,
610 output: &crate::EmergencyDiagnosticHandle,
611 axes: &saddle_core::DiagnosticOutcomeAxes,
612 diagnostic: Option<&saddle_core::BoundedDiagnostic>,
613 ) -> crate::DiagnosticSubmission {
614 self.finish_bounded_reference(output, axes, diagnostic.map(|d| d.occurrence()))
615 }
616 pub fn finish_bounded_reference(
618 mut self,
619 output: &crate::EmergencyDiagnosticHandle,
620 axes: &saddle_core::DiagnosticOutcomeAxes,
621 occurrence: Option<saddle_core::DiagnosticOccurrence>,
622 ) -> crate::DiagnosticSubmission {
623 let elapsed = self.finish_bounded_metrics(axes);
624 output.submit_boundary_parts(
625 occurrence,
626 axes,
627 Some((&self.context, self.request.as_str(), self.route.as_str())),
628 Some((self.stage.as_str(), elapsed)),
629 )
630 }
631 pub fn context(&self) -> &CallContext {
632 &self.context
633 }
634 pub fn succeed(mut self) {
635 self.finish(StageOutcome::Success, None);
636 }
637 pub fn reject(mut self, error: &SaddleError) {
638 self.finish(StageOutcome::Rejected, Some(error));
639 }
640 pub fn fail(mut self, error: &SaddleError) {
641 self.finish(StageOutcome::Failure, Some(error));
642 }
643
644 fn emit_started(&self) {
645 let mut record = self.record(EventLevel::Info, "framework.stage.started");
646 record
647 .data
648 .insert("outcome".into(), Value::String("started".into()));
649 self.observer.emit(record);
650 }
651
652 fn finish(&mut self, outcome: StageOutcome, error: Option<&SaddleError>) {
653 if self.finished {
654 return;
655 }
656 let mut record = self.record(
657 if outcome == StageOutcome::Success {
658 EventLevel::Info
659 } else {
660 EventLevel::Error
661 },
662 "framework.stage.finished",
663 );
664 record
665 .data
666 .insert("outcome".into(), Value::String(outcome.as_str().into()));
667 let elapsed_ms = u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
668 record.data.insert("elapsed_ms".into(), json!(elapsed_ms));
669 if let Some(error) = error {
670 record
671 .data
672 .insert("error_code".into(), Value::String(error.code().to_owned()));
673 record.data.insert(
674 "error_kind".into(),
675 Value::String(error_kind(error.kind()).into()),
676 );
677 }
678 self.observer
679 .inner
680 .metrics
681 .stage_finished(self.stage, outcome, elapsed_ms);
682 self.observer.emit(record);
683 self.finished = true;
684 }
685
686 fn record(&self, level: EventLevel, event: &'static str) -> LogRecord {
687 let mut record = base_record(&self.context, level, event, self.stage.as_str());
688 record.parent = Some(self.parent_span.clone());
689 record.parent_span_id = Some(self.parent_span.clone());
690 record.data.insert(
691 "request_identity".into(),
692 Value::String(self.request.0.clone()),
693 );
694 record
695 .data
696 .insert("route".into(), Value::String(self.route.0.clone()));
697 record.data.insert("attempt".into(), json!(self.attempt));
698 record.data.insert("elapsed_ms".into(), json!(0));
699 record
700 .data
701 .insert("error_code".into(), Value::String("none".into()));
702 record
703 }
704}
705
706impl Drop for ActiveStage {
707 fn drop(&mut self) {
708 self.finish(StageOutcome::Cancelled, None);
709 }
710}
711
712pub struct CapacityObservation {
713 dimension: Option<CapacityDimension>,
714 budget: u64,
715 limit: u64,
716 used: u64,
717 bottleneck: BottleneckIdentity,
718 reject_reason: Option<RejectReason>,
719 elapsed_ms: u64,
720}
721
722impl CapacityObservation {
723 pub fn accepted(budget: u64, limit: u64, used: u64, bottleneck: BottleneckIdentity) -> Self {
724 Self {
725 dimension: None,
726 budget,
727 limit,
728 used,
729 bottleneck,
730 reject_reason: None,
731 elapsed_ms: 0,
732 }
733 }
734 pub fn rejected(
735 budget: u64,
736 limit: u64,
737 used: u64,
738 bottleneck: BottleneckIdentity,
739 reason: RejectReason,
740 ) -> Self {
741 Self {
742 dimension: None,
743 budget,
744 limit,
745 used,
746 bottleneck,
747 reject_reason: Some(reason),
748 elapsed_ms: 0,
749 }
750 }
751
752 pub fn accepted_dimension(
753 dimension: CapacityDimension,
754 budget: u64,
755 limit: u64,
756 used: u64,
757 bottleneck: BottleneckIdentity,
758 elapsed_ms: u64,
759 ) -> Self {
760 Self {
761 dimension: Some(dimension),
762 budget,
763 limit,
764 used,
765 bottleneck,
766 reject_reason: None,
767 elapsed_ms,
768 }
769 }
770
771 pub fn rejected_dimension(
772 dimension: CapacityDimension,
773 budget: u64,
774 limit: u64,
775 used: u64,
776 bottleneck: BottleneckIdentity,
777 reason: RejectReason,
778 elapsed_ms: u64,
779 ) -> Self {
780 Self {
781 dimension: Some(dimension),
782 budget,
783 limit,
784 used,
785 bottleneck,
786 reject_reason: Some(reason),
787 elapsed_ms,
788 }
789 }
790}
791
792#[derive(Clone, Copy, Debug, Eq, PartialEq)]
793pub enum CapacityDimension {
794 Cpu,
795 Memory,
796 Database,
797 ProfuseContract,
798}
799
800impl CapacityDimension {
801 const fn as_str(self) -> &'static str {
802 match self {
803 Self::Cpu => "cpu",
804 Self::Memory => "memory",
805 Self::Database => "database",
806 Self::ProfuseContract => "profuse_contract",
807 }
808 }
809}
810
811#[derive(Clone, Copy, Debug, Eq, PartialEq)]
812pub enum DatabaseDisposition {
813 NotUsed,
814 Returned,
815 Discarded,
816}
817impl DatabaseDisposition {
818 const fn as_str(self) -> &'static str {
819 match self {
820 Self::NotUsed => "not_used",
821 Self::Returned => "returned",
822 Self::Discarded => "discarded",
823 }
824 }
825}
826
827pub struct OutboundObservation {
828 zone: RouteIdentity,
829 authority: OutboundAuthority,
830 result: OutboundResult,
831}
832impl OutboundObservation {
833 pub fn new(zone: RouteIdentity, authority: OutboundAuthority, result: OutboundResult) -> Self {
834 Self {
835 zone,
836 authority,
837 result,
838 }
839 }
840}
841
842#[derive(Clone, Copy, Debug, Eq, PartialEq)]
843pub enum OutboundResult {
844 Success,
845 Failure,
846 Rejected,
847 Timeout,
848}
849impl OutboundResult {
850 const fn as_str(self) -> &'static str {
851 match self {
852 Self::Success => "success",
853 Self::Failure => "failure",
854 Self::Rejected => "rejected",
855 Self::Timeout => "timeout",
856 }
857 }
858}
859
860#[derive(Clone, Copy, Debug, Eq, PartialEq)]
861pub enum LifecycleState {
862 Starting,
863 Running,
864 Draining,
865 Stopped,
866}
867impl LifecycleState {
868 const fn as_str(self) -> &'static str {
869 match self {
870 Self::Starting => "starting",
871 Self::Running => "running",
872 Self::Draining => "draining",
873 Self::Stopped => "stopped",
874 }
875 }
876}
877
878#[derive(Clone, Copy, Debug, Eq, PartialEq)]
879pub enum Health {
880 Healthy,
881 Degraded,
882 Failed,
883}
884impl Health {
885 const fn as_str(self) -> &'static str {
886 match self {
887 Self::Healthy => "healthy",
888 Self::Degraded => "degraded",
889 Self::Failed => "failed",
890 }
891 }
892}
893
894fn base_record(
895 context: &CallContext,
896 level: EventLevel,
897 event: &'static str,
898 stage: &'static str,
899) -> LogRecord {
900 let mut record = LogRecord::new(level, event);
901 record.trace_id = Some(context.trace_correlation_id().to_string());
902 record.span = Some(stage.into());
903 record.span_id = Some(context.span_id().to_string());
904 record
905 .data
906 .insert("timestamp".into(), json!(record.timestamp_unix_ms));
907 record
908 .data
909 .insert("stage".into(), Value::String(stage.into()));
910 record.data.insert(
911 "rpc_id".into(),
912 context
913 .rpc_correlation_id()
914 .map(|rpc| Value::String(rpc.as_str().into()))
915 .unwrap_or(Value::Null),
916 );
917 record.data.insert(
918 "request".into(),
919 Value::String(context.operation().to_string()),
920 );
921 record
922}
923
924fn add_event_context(record: &mut LogRecord, context: &EventContext) {
925 record.data.insert(
926 "request_identity".into(),
927 Value::String(context.request.0.clone()),
928 );
929 record
930 .data
931 .insert("route".into(), Value::String(context.route.0.clone()));
932 record.data.insert("attempt".into(), json!(context.attempt));
933 record.data.insert("elapsed_ms".into(), json!(0));
934 record
935 .data
936 .insert("error_code".into(), Value::String("none".into()));
937}
938
939const fn error_kind(kind: ErrorKind) -> &'static str {
940 match kind {
941 ErrorKind::InvalidArgument => "invalid_argument",
942 ErrorKind::NotFound => "not_found",
943 ErrorKind::Conflict => "conflict",
944 ErrorKind::Business => "business",
945 ErrorKind::Unavailable => "unavailable",
946 ErrorKind::Infrastructure => "infrastructure",
947 ErrorKind::Internal => "internal",
948 _ => "unknown",
949 }
950}
951
952#[cfg(test)]
953mod tests {
954 use std::{
955 future::Future,
956 io,
957 sync::{Arc, Mutex},
958 task::{Context, Poll, Wake, Waker},
959 thread,
960 };
961
962 use super::*;
963 use crate::ObserverConfig;
964
965 #[derive(Clone, Default)]
966 struct Capture(Arc<Mutex<Vec<u8>>>);
967
968 impl io::Write for Capture {
969 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
970 self.0.lock().unwrap().extend_from_slice(bytes);
971 Ok(bytes.len())
972 }
973 fn flush(&mut self) -> io::Result<()> {
974 Ok(())
975 }
976 }
977
978 struct ThreadWaker(thread::Thread);
979 impl Wake for ThreadWaker {
980 fn wake(self: Arc<Self>) {
981 self.0.unpark();
982 }
983 }
984
985 fn block_on<T>(future: impl Future<Output = T>) -> T {
986 let waker = Waker::from(Arc::new(ThreadWaker(thread::current())));
987 let mut context = Context::from_waker(&waker);
988 let mut future = std::pin::pin!(future);
989 loop {
990 match future.as_mut().poll(&mut context) {
991 Poll::Ready(output) => return output,
992 Poll::Pending => thread::park(),
993 }
994 }
995 }
996
997 fn event_context(attempt: u32) -> EventContext {
998 EventContext::new(
999 RequestIdentity::new("request-7").unwrap(),
1000 RouteIdentity::new("orders.create").unwrap(),
1001 attempt,
1002 )
1003 .unwrap()
1004 }
1005
1006 #[test]
1007 fn transaction_terminal_serial_scope_schema_and_foreign_restore() {
1008 let capture = Capture::default();
1009 let observer = Observer::with_writer(ObserverConfig::default(), capture.clone()).unwrap();
1010 let (root, _) = observer
1011 .start_external_call_with_rpc(
1012 "shop",
1013 "entry",
1014 "orders",
1015 "create",
1016 Some("safe-trace"),
1017 saddle_core::RpcCorrelationId::new("0").unwrap(),
1018 )
1019 .unwrap();
1020 let (startup, issuer) =
1021 saddle_core::DbPhysicalDispositionIssuer::issue().into_startup_and_request_issuer();
1022 let process = startup.into_process_capability();
1023 let (mut request, mut execution) = issuer.issue_request().unwrap();
1024 let (_, foreign) = issuer.issue_request().unwrap();
1025 for outcome in [
1026 TransactionOutcome::Committed,
1027 TransactionOutcome::Rejected,
1028 TransactionOutcome::Unknown,
1029 ] {
1030 let observation = request
1031 .take_scope_observation(
1032 &execution,
1033 (observer.clone(), root.context().clone(), event_context(1)),
1034 )
1035 .ok()
1036 .unwrap();
1037 let observation = observation.bind_terminal(&foreign).err().unwrap();
1038 let observation = observation.bind_terminal(&execution).ok().unwrap();
1039 Observer::record_transaction_terminal(observation, outcome);
1040 assert!(request.take_scope_observation(&execution, ()).is_err());
1041 let physical = process.connection_returned(execution, ()).ok().unwrap();
1042 let (next, _, _) = saddle_core::pair_db_physical_disposition(physical, request)
1043 .ok()
1044 .unwrap()
1045 .into_scope_continuation();
1046 (request, execution) = next.into_next_scope().ok().unwrap();
1047 }
1048 block_on(observer.flush()).unwrap();
1049 let rows: Vec<_> = records(&capture)
1050 .into_iter()
1051 .filter(|r| r["event"] == "database_transaction_terminal")
1052 .collect();
1053 assert_eq!(rows.len(), 3);
1054 for (index, outcome) in ["Committed", "Rejected", "Unknown"].into_iter().enumerate() {
1055 let row = &rows[index];
1056 assert_eq!(row["transaction_scope"], index);
1057 assert_eq!(row["transaction_outcome"], outcome);
1058 assert_eq!(row["trace_id"], "safe-trace");
1059 assert_eq!(row["rpc_id"], "0");
1060 assert_eq!(row["span_id"], root.context().span_id().to_string());
1061 assert_ne!(row["rpc_id"], row["span_id"]);
1062 assert_eq!(row["request_identity"], "request-7");
1063 assert_eq!(row["route"], "orders.create");
1064 let mut keys: Vec<_> = row
1065 .as_object()
1066 .unwrap()
1067 .keys()
1068 .map(String::as_str)
1069 .collect();
1070 keys.sort_unstable();
1071 let mut expected = vec![
1072 "timestamp_unix_ms",
1073 "timestamp",
1074 "level",
1075 "event",
1076 "stage",
1077 "trace_id",
1078 "rpc_id",
1079 "span",
1080 "span_id",
1081 "request",
1082 "request_identity",
1083 "route",
1084 "attempt",
1085 "elapsed_ms",
1086 "error_code",
1087 "outcome",
1088 "transaction_scope",
1089 "transaction_outcome",
1090 ];
1091 expected.sort_unstable();
1092 assert_eq!(keys, expected); }
1094 root.succeed();
1095 block_on(observer.shutdown()).unwrap();
1096 }
1097
1098 #[test]
1099 fn transaction_terminal_full_queue_never_waits_for_writer() {
1100 use std::sync::mpsc;
1101 use std::time::Duration;
1102 struct PausedWriter {
1103 entered: Option<mpsc::Sender<()>>,
1104 release: mpsc::Receiver<()>,
1105 }
1106 impl io::Write for PausedWriter {
1107 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
1108 if let Some(entered) = self.entered.take() {
1109 entered.send(()).unwrap();
1110 self.release.recv().unwrap();
1111 }
1112 Ok(bytes.len())
1113 }
1114 fn flush(&mut self) -> io::Result<()> {
1115 Ok(())
1116 }
1117 }
1118 let (entered, reached) = mpsc::channel();
1119 let (release, gate) = mpsc::channel();
1120 let observer = Observer::with_writer(
1121 ObserverConfig { queue_capacity: 1 },
1122 PausedWriter {
1123 entered: Some(entered),
1124 release: gate,
1125 },
1126 )
1127 .unwrap();
1128 let (root, _) = observer.start_external_call("shop", "entry", "orders", "create", None);
1129 reached.recv_timeout(Duration::from_secs(5)).unwrap();
1130 observer.emit(LogRecord::new(EventLevel::Info, "fill"));
1131 let (_, issuer) =
1132 saddle_core::DbPhysicalDispositionIssuer::issue().into_startup_and_request_issuer();
1133 let (mut request, execution) = issuer.issue_request().unwrap();
1134 let observation = request
1135 .take_scope_observation(
1136 &execution,
1137 (observer.clone(), root.context().clone(), event_context(1)),
1138 )
1139 .ok()
1140 .unwrap()
1141 .bind_terminal(&execution)
1142 .ok()
1143 .unwrap();
1144 let (done, completion) = mpsc::channel();
1145 let emitter = thread::spawn(move || {
1146 Observer::record_transaction_terminal(observation, TransactionOutcome::Unknown);
1147 done.send(()).unwrap();
1148 });
1149 let result = completion.recv_timeout(Duration::from_secs(2));
1150 let dropped = observer.dropped_events();
1151 release.send(()).unwrap(); emitter.join().unwrap();
1153 assert!(result.is_ok());
1154 assert_eq!(dropped, 1);
1155 let _ = block_on(observer.flush());
1156 root.succeed();
1157 let _ = block_on(observer.shutdown());
1158 }
1159
1160 fn records(capture: &Capture) -> Vec<Value> {
1161 String::from_utf8(capture.0.lock().unwrap().clone())
1162 .unwrap()
1163 .lines()
1164 .map(|line| serde_json::from_str(line).unwrap())
1165 .collect()
1166 }
1167
1168 #[test]
1169 fn lifecycle_timeout_is_typed_and_contains_no_application_payload() {
1170 let capture = Capture::default();
1171 let observer = Observer::with_writer(ObserverConfig::default(), capture.clone()).unwrap();
1172 observer.record_lifecycle_timeout(
1173 "profusegw",
1174 LifecycleTimeoutStage::ComponentShutdown,
1175 30_000,
1176 );
1177 block_on(observer.flush()).unwrap();
1178
1179 let records = records(&capture);
1180 let timeout = records
1181 .iter()
1182 .find(|record| record["event"] == "framework.lifecycle.timeout")
1183 .unwrap();
1184 assert_eq!(timeout["timeout_stage"], "component_shutdown");
1185 assert_eq!(timeout["elapsed_ms"], 30_000);
1186 assert_eq!(timeout["outcome"], "timeout");
1187 assert!(timeout.get("payload").is_none());
1188 }
1189
1190 #[test]
1191 fn stage_chain_is_correlated_ordered_and_has_one_terminal() {
1192 let capture = Capture::default();
1193 let observer = Observer::with_writer(ObserverConfig::default(), capture.clone()).unwrap();
1194 let (root, _) = observer.start_external_call(
1195 "shop",
1196 "entry",
1197 "orders",
1198 "create",
1199 Some("00112233445566778899aabbccddeeff"),
1200 );
1201 let stages = [
1202 Stage::Ingress,
1203 Stage::Admission,
1204 Stage::Handler,
1205 Stage::Database,
1206 Stage::ProfuseContract,
1207 Stage::Response,
1208 Stage::ResourceFinalization,
1209 ];
1210 for (index, stage) in stages.into_iter().enumerate() {
1211 observer
1212 .start_stage(root.context(), stage, event_context((index + 1) as u32))
1213 .succeed();
1214 }
1215 drop(observer.start_stage(root.context(), Stage::Handler, event_context(8)));
1216 root.succeed();
1217 block_on(observer.flush()).unwrap();
1218
1219 let records = records(&capture);
1220 let stage_records: Vec<_> = records
1221 .iter()
1222 .filter(|value| {
1223 value["event"]
1224 .as_str()
1225 .unwrap()
1226 .starts_with("framework.stage.")
1227 })
1228 .collect();
1229 assert_eq!(stage_records.len(), 16);
1230 for pair in stage_records.chunks_exact(2) {
1231 assert_eq!(pair[0]["event"], "framework.stage.started");
1232 assert_eq!(pair[1]["event"], "framework.stage.finished");
1233 assert_eq!(pair[0]["rpc_id"], pair[1]["rpc_id"]);
1234 assert_eq!(pair[0]["trace_id"], "00112233445566778899aabbccddeeff");
1235 for field in [
1236 "timestamp_unix_ms",
1237 "timestamp",
1238 "level",
1239 "event",
1240 "stage",
1241 "trace_id",
1242 "rpc_id",
1243 "request_identity",
1244 "route",
1245 "attempt",
1246 "outcome",
1247 "error_code",
1248 ] {
1249 assert!(pair[0].get(field).is_some(), "missing {field}");
1250 }
1251 assert!(pair[1].get("elapsed_ms").is_some());
1252 }
1253 assert_eq!(stage_records.last().unwrap()["outcome"], "cancelled");
1254 }
1255
1256 #[test]
1257 fn closed_observations_have_common_fields_and_no_sensitive_payload() {
1258 let capture = Capture::default();
1259 let observer = Observer::with_writer(ObserverConfig::default(), capture.clone()).unwrap();
1260 let (root, _) = observer.start_external_call("shop", "entry", "orders", "create", None);
1261 let common = event_context(1);
1262 observer.record_capacity(
1263 root.context(),
1264 &common,
1265 CapacityObservation::rejected(
1266 100,
1267 80,
1268 80,
1269 BottleneckIdentity::new("request_slots").unwrap(),
1270 RejectReason::new("limit_reached").unwrap(),
1271 ),
1272 );
1273 observer.record_database_disposition(
1274 root.context(),
1275 &common,
1276 DatabaseDisposition::Returned,
1277 );
1278 observer.record_outbound(
1279 root.context(),
1280 &common,
1281 OutboundObservation::new(
1282 RouteIdentity::new("cn-hz-a").unwrap(),
1283 OutboundAuthority::new("inventory-service").unwrap(),
1284 OutboundResult::Success,
1285 ),
1286 );
1287 observer.record_lifecycle(
1288 root.context(),
1289 &common,
1290 LifecycleState::Draining,
1291 Health::Healthy,
1292 );
1293 observer.record_logger_health(root.context(), &common, 3, Some(OutputStage::Record));
1294 root.succeed();
1295 let _ = block_on(observer.flush());
1296
1297 let records: Vec<_> = records(&capture)
1298 .into_iter()
1299 .filter(|value| {
1300 matches!(
1301 value["event"].as_str(),
1302 Some(
1303 "framework.capacity"
1304 | "framework.database.disposition"
1305 | "framework.outbound"
1306 | "framework.lifecycle"
1307 | "framework.logger.health"
1308 )
1309 )
1310 })
1311 .collect();
1312 assert_eq!(records.len(), 5);
1313 for record in records {
1314 for field in [
1315 "timestamp_unix_ms",
1316 "timestamp",
1317 "level",
1318 "event",
1319 "stage",
1320 "trace_id",
1321 "rpc_id",
1322 "request_identity",
1323 "route",
1324 "attempt",
1325 "elapsed_ms",
1326 "outcome",
1327 "error_code",
1328 ] {
1329 assert!(record.get(field).is_some(), "missing {field}");
1330 }
1331 let encoded = serde_json::to_string(&record).unwrap();
1332 for forbidden in [
1333 "request_body",
1334 "response_body",
1335 "cookie",
1336 "session",
1337 "token",
1338 "password",
1339 "connection_string",
1340 "db_value",
1341 ] {
1342 assert!(!encoded.contains(forbidden));
1343 }
1344 }
1345 }
1346
1347 #[test]
1348 fn fixed_metrics_snapshot_tracks_closed_low_cardinality_events() {
1349 let observer = Observer::with_writer(ObserverConfig::default(), io::sink()).unwrap();
1350 let (root, _) = observer.start_external_call("shop", "entry", "orders", "create", None);
1351 let common = event_context(1);
1352 observer
1353 .start_stage(root.context(), Stage::Response, event_context(1))
1354 .succeed();
1355 observer.record_capacity(
1356 root.context(),
1357 &common,
1358 CapacityObservation::rejected_dimension(
1359 CapacityDimension::Database,
1360 100,
1361 80,
1362 80,
1363 BottleneckIdentity::new("database_slots").unwrap(),
1364 RejectReason::new("limit_reached").unwrap(),
1365 2,
1366 ),
1367 );
1368 observer.record_database_disposition(
1369 root.context(),
1370 &common,
1371 DatabaseDisposition::Discarded,
1372 );
1373 observer.record_outbound(
1374 root.context(),
1375 &common,
1376 OutboundObservation::new(
1377 RouteIdentity::new("cn-hz-a").unwrap(),
1378 OutboundAuthority::new("inventory-service").unwrap(),
1379 OutboundResult::Timeout,
1380 ),
1381 );
1382 observer.record_logger_health(root.context(), &common, 3, Some(OutputStage::Record));
1383 observer.record_lifecycle(
1384 root.context(),
1385 &common,
1386 LifecycleState::Running,
1387 Health::Healthy,
1388 );
1389
1390 let snapshot = observer.metrics_snapshot();
1391 assert_eq!(snapshot.requests(StageOutcome::Success), 1);
1392 assert_eq!(
1393 snapshot.stage_latency(Stage::Response).iter().sum::<u64>(),
1394 1
1395 );
1396 assert_eq!(
1397 snapshot.capacity_rejected(Some(CapacityDimension::Database)),
1398 1
1399 );
1400 assert_eq!(
1401 snapshot.capacity_used(Some(CapacityDimension::Database)),
1402 80
1403 );
1404 assert_eq!(snapshot.database(DatabaseDisposition::Discarded), 1);
1405 assert_eq!(snapshot.outbound(OutboundResult::Timeout), 1);
1406 assert_eq!(snapshot.logger_dropped(), 0);
1407 assert_eq!(snapshot.logger_output_failed(), 1);
1408 assert_eq!(snapshot.lifecycle(), LifecycleState::Running);
1409 assert_eq!(snapshot.health(), Health::Healthy);
1410 assert!(snapshot.ready());
1411 root.succeed();
1412 }
1413
1414 #[test]
1415 fn typed_capacity_and_resource_terminal_preserve_closed_schema() {
1416 let capture = Capture::default();
1417 let observer = Observer::with_writer(ObserverConfig::default(), capture.clone()).unwrap();
1418 let (root, _) = observer.start_external_call("shop", "entry", "orders", "create", None);
1419 let common = event_context(1);
1420 observer.record_capacity(
1421 root.context(),
1422 &common,
1423 CapacityObservation::rejected_dimension(
1424 CapacityDimension::Database,
1425 4,
1426 2,
1427 2,
1428 BottleneckIdentity::new("database").unwrap(),
1429 RejectReason::new("at_limit").unwrap(),
1430 17,
1431 ),
1432 );
1433 observer.record_resource_finalization(
1434 root.context(),
1435 &common,
1436 DatabaseDisposition::NotUsed,
1437 23,
1438 );
1439 root.succeed();
1440 block_on(observer.flush()).unwrap();
1441
1442 let records = records(&capture);
1443 let capacity = records
1444 .iter()
1445 .find(|record| record["event"] == "framework.capacity")
1446 .unwrap();
1447 assert_eq!(capacity["capacity_dimension"], "database");
1448 assert_eq!(capacity["budget"], 4);
1449 assert_eq!(capacity["limit"], 2);
1450 assert_eq!(capacity["used"], 2);
1451 assert_eq!(capacity["bottleneck"], "database");
1452 assert_eq!(capacity["reject_reason"], "at_limit");
1453 assert_eq!(capacity["elapsed_ms"], 17);
1454
1455 let terminal = records
1456 .iter()
1457 .find(|record| record["event"] == "framework.resource.finalized")
1458 .unwrap();
1459 assert_eq!(terminal["stage"], "resource_finalization");
1460 assert_eq!(terminal["credit"], "released");
1461 assert_eq!(terminal["db_disposition"], "not_used");
1462 assert_eq!(terminal["elapsed_ms"], 23);
1463 assert_eq!(terminal["outcome"], "success");
1464 assert_eq!(capacity["trace_id"], terminal["trace_id"]);
1465 assert_eq!(capacity["rpc_id"], terminal["rpc_id"]);
1466 assert_eq!(capacity["request_identity"], terminal["request_identity"]);
1467 assert_eq!(capacity["route"], terminal["route"]);
1468 assert_eq!(capacity["attempt"], terminal["attempt"]);
1469 }
1470
1471 #[test]
1472 fn unsafe_or_unbounded_identifiers_and_zero_attempt_are_rejected() {
1473 assert_eq!(RequestIdentity::new(""), Err(ChainFieldError::Empty));
1474 assert_eq!(
1475 RouteIdentity::new("x".repeat(257)),
1476 Err(ChainFieldError::TooLong)
1477 );
1478 assert_eq!(
1479 RejectReason::new("bad\nreason"),
1480 Err(ChainFieldError::ControlCharacter)
1481 );
1482 for unsafe_value in ["https://user:secret@host/path", "host/path", "host?token=x"] {
1483 assert_eq!(
1484 OutboundAuthority::new(unsafe_value),
1485 Err(ChainFieldError::UnsafeAuthority)
1486 );
1487 }
1488 assert_eq!(
1489 EventContext::new(
1490 RequestIdentity::new("r").unwrap(),
1491 RouteIdentity::new("route").unwrap(),
1492 0
1493 ),
1494 Err(ChainFieldError::ZeroAttempt),
1495 );
1496 }
1497}