Skip to main content

saddle_observability/
chain.rs

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/// DB owning-driver facts, not an inference from HTTP or connection disposal.
115#[derive(Clone, Copy, Debug, Eq, PartialEq)]
116pub enum TransactionOutcome {
117    /// COMMIT acknowledgement was received, even if the client later disconnects.
118    Committed,
119    /// No possible commit was entered. This does not prove rollback acknowledgement.
120    Rejected,
121    /// COMMIT may have executed but its acknowledgement is unavailable.
122    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
135/// Same-request/scope carrier checked by Core and transported by Runtime.
136/// It is consumed once; it is not proof of transaction outcome or log durability.
137pub 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    /// DB owning driver calls once before returning its result/physical receipt.
198    /// Uses the carrier's observer/context, with no caller-supplied scope or sink.
199    /// Queue loss preserves the DB result; a missing log remains NOT_PROVEN.
200    ///
201    /// ```compile_fail
202    /// use saddle_observability::{Observer, TransactionOutcome, TransactionTerminalObservation};
203    /// fn replay(observation: TransactionTerminalObservation) {
204    ///     Observer::record_transaction_terminal(observation, TransactionOutcome::Committed);
205    ///     Observer::record_transaction_terminal(observation, TransactionOutcome::Rejected);
206    /// }
207    /// ```
208    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        // Core's concrete Serialize-only payload has exactly one numeric field.
225        // A serialization failure drops this attempt; it never changes DB outcome.
226        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    /// Update the existing fixed-label counter without allocating or emitting a
353    /// second log. The DB owning terminal path calls this exactly once with its
354    /// actual physical disposition, independently of operation outcome.
355    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    /// Finish a non-technical result without inventing a source receipt. Only
546    /// Succeeded/Rejected are accepted; all other axes return the untouched stage
547    /// for the caller to finish through its actual diagnostic receipt path.
548    #[allow(clippy::result_large_err)] // Returning the owner must not allocate.
549    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    /// Ends this stage once using the source receipt's original context. Missing
574    /// output changes delivery status only, never the operation or Drop outcome.
575    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    /// Finish from independent source-owned axes without constructing SaddleError
607    /// or allocating a LogRecord. Delivery failure never retries the stage.
608    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    /// Finish with correlation only; never repeats the source detail.
617    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); // No SQL/parameters/business error or arbitrary fields.
1093        }
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(); // Release even if a regression blocked the emitter.
1152        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}