Skip to main content

oxide_batch/
diagnostics.rs

1//! Facade-owned lifecycle events and value-redacted diagnostic projections.
2
3use std::fmt;
4use std::num::NonZeroU64;
5use std::time::Duration;
6
7use crate::{
8    BatchStatus, ChunkCount, FailureSummary, FaultPhase, JobExecutionId, JobInstanceId, JobName,
9    RetryOrdinal, StepExecutionId, StepName,
10};
11
12/// A nonzero, instance-scoped execution-attempt ordinal.
13#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
14pub struct ExecutionAttempt(NonZeroU64);
15
16impl ExecutionAttempt {
17    /// Constructs an attempt ordinal from a nonzero value.
18    #[must_use]
19    pub const fn new(value: NonZeroU64) -> Self {
20        Self(value)
21    }
22
23    /// Returns the numeric attempt ordinal.
24    #[must_use]
25    pub const fn get(self) -> u64 {
26        self.0.get()
27    }
28}
29
30impl fmt::Display for ExecutionAttempt {
31    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32        self.get().fmt(formatter)
33    }
34}
35
36/// Stable, bounded identifiers shared by job and step diagnostics.
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct ExecutionCorrelation {
39    job_name: JobName,
40    job_instance_id: JobInstanceId,
41    job_execution_id: JobExecutionId,
42    job_attempt: ExecutionAttempt,
43    step_name: StepName,
44    step_execution_id: StepExecutionId,
45    step_attempt: ExecutionAttempt,
46}
47
48impl ExecutionCorrelation {
49    /// Constructs complete correlation for a single-step execution graph.
50    #[must_use]
51    #[allow(clippy::too_many_arguments)]
52    pub const fn new(
53        job_name: JobName,
54        job_instance_id: JobInstanceId,
55        job_execution_id: JobExecutionId,
56        job_attempt: ExecutionAttempt,
57        step_name: StepName,
58        step_execution_id: StepExecutionId,
59        step_attempt: ExecutionAttempt,
60    ) -> Self {
61        Self {
62            job_name,
63            job_instance_id,
64            job_execution_id,
65            job_attempt,
66            step_name,
67            step_execution_id,
68            step_attempt,
69        }
70    }
71
72    /// Borrows the job definition name.
73    #[must_use]
74    pub const fn job_name(&self) -> &JobName {
75        &self.job_name
76    }
77
78    /// Returns the logical job-instance identifier.
79    #[must_use]
80    pub const fn job_instance_id(&self) -> JobInstanceId {
81        self.job_instance_id
82    }
83
84    /// Returns the job-attempt identifier.
85    #[must_use]
86    pub const fn job_execution_id(&self) -> JobExecutionId {
87        self.job_execution_id
88    }
89
90    /// Returns the instance-scoped job attempt.
91    #[must_use]
92    pub const fn job_attempt(&self) -> ExecutionAttempt {
93        self.job_attempt
94    }
95
96    /// Borrows the step definition name.
97    #[must_use]
98    pub const fn step_name(&self) -> &StepName {
99        &self.step_name
100    }
101
102    /// Returns the step-attempt identifier.
103    #[must_use]
104    pub const fn step_execution_id(&self) -> StepExecutionId {
105        self.step_execution_id
106    }
107
108    /// Returns the instance-scoped step attempt.
109    #[must_use]
110    pub const fn step_attempt(&self) -> ExecutionAttempt {
111        self.step_attempt
112    }
113}
114
115/// Stable severity for a lifecycle event.
116#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
117#[non_exhaustive]
118pub enum EventSeverity {
119    /// High-volume detail disabled by default.
120    Debug,
121    /// Normal lifecycle progress.
122    Info,
123    /// A cooperative stop or recoverable condition.
124    Warn,
125    /// A failed lifecycle or user-component boundary.
126    Error,
127}
128
129impl EventSeverity {
130    /// Returns the stable lowercase representation.
131    #[must_use]
132    pub const fn as_str(self) -> &'static str {
133        match self {
134            Self::Debug => "debug",
135            Self::Info => "info",
136            Self::Warn => "warn",
137            Self::Error => "error",
138        }
139    }
140}
141
142impl fmt::Display for EventSeverity {
143    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
144        formatter.write_str(self.as_str())
145    }
146}
147
148/// The framework component associated with an event.
149#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
150#[non_exhaustive]
151pub enum EventComponent {
152    /// The launch facade.
153    Launcher,
154    /// A job execution.
155    Job,
156    /// A step execution.
157    Step,
158    /// A bounded chunk transaction.
159    Chunk,
160    /// A job or step listener boundary.
161    Listener,
162    /// A bounded retry scope.
163    Retry,
164    /// One item classified by a fault policy.
165    Item,
166    /// A rollback or no-rollback classification.
167    Fault,
168    /// Durable flow traversal.
169    Flow,
170    /// Repository command/query behavior.
171    Repository,
172    /// Checkpoint lifecycle.
173    Checkpoint,
174    /// Guarded operator services.
175    Operator,
176    /// Bounded explorer services.
177    Explorer,
178    /// Graceful process shutdown.
179    Shutdown,
180    /// Stale detection and recovery.
181    Recovery,
182    /// Guarded retention.
183    Retention,
184    /// Local split execution.
185    Split,
186    /// Local partition execution.
187    Partition,
188    /// Export infrastructure.
189    Telemetry,
190    /// Metadata migration.
191    Migration,
192}
193
194impl EventComponent {
195    /// Returns the stable lowercase representation.
196    #[must_use]
197    pub const fn as_str(self) -> &'static str {
198        match self {
199            Self::Launcher => "launcher",
200            Self::Job => "job",
201            Self::Step => "step",
202            Self::Chunk => "chunk",
203            Self::Listener => "listener",
204            Self::Retry => "retry",
205            Self::Item => "item",
206            Self::Fault => "fault",
207            Self::Flow => "flow",
208            Self::Repository => "repository",
209            Self::Checkpoint => "checkpoint",
210            Self::Operator => "operator",
211            Self::Explorer => "explorer",
212            Self::Shutdown => "shutdown",
213            Self::Recovery => "recovery",
214            Self::Retention => "retention",
215            Self::Split => "split",
216            Self::Partition => "partition",
217            Self::Telemetry => "telemetry",
218            Self::Migration => "migration",
219        }
220    }
221}
222
223impl fmt::Display for EventComponent {
224    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
225        formatter.write_str(self.as_str())
226    }
227}
228
229/// Stable M1 lifecycle event names.
230#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
231#[non_exhaustive]
232pub enum LifecycleEventKind {
233    /// The repository accepted a launch and created its execution graph.
234    LaunchAccepted,
235    /// Job metadata is durably `STARTING`.
236    JobStarting,
237    /// Step metadata is durably `STARTING`.
238    StepStarting,
239    /// Job metadata is durably `STARTED`.
240    JobStarted,
241    /// Step metadata is durably `STARTED`.
242    StepStarted,
243    /// Job metadata is durably `STOPPING`.
244    JobStopping,
245    /// Step metadata is durably `STOPPING`.
246    StepStopping,
247    /// Job metadata is durably `STOPPED`.
248    JobStopped,
249    /// Step metadata is durably `STOPPED`.
250    StepStopped,
251    /// Job metadata is durably `COMPLETED`.
252    JobCompleted,
253    /// Step metadata is durably `COMPLETED`.
254    StepCompleted,
255    /// Job metadata is durably `FAILED`.
256    JobFailed,
257    /// Step metadata is durably `FAILED`.
258    StepFailed,
259    /// Job metadata is durably `UNKNOWN`.
260    JobUnknown,
261    /// Step metadata is durably `UNKNOWN`.
262    StepUnknown,
263    /// A bounded chunk transaction is starting.
264    ChunkStarted,
265    /// A bounded chunk transaction committed.
266    ChunkCommitted,
267    /// A bounded chunk transaction rolled back.
268    ChunkRolledBack,
269    /// A chunk commit result is unknown.
270    ChunkUnknown,
271    /// A job before-listener returned an error or panicked.
272    JobBeforeListenerFailed,
273    /// A job after-listener returned an error or panicked.
274    JobAfterListenerFailed,
275    /// A step before-listener returned an error or panicked.
276    StepBeforeListenerFailed,
277    /// A step after-listener returned an error or panicked.
278    StepAfterListenerFailed,
279    /// A retry ordinal became durably reserved.
280    RetryReserved,
281    /// A cancellable backoff wait started.
282    RetryBackoffStarted,
283    /// Cooperative stop cancelled a backoff wait.
284    RetryBackoffCancelled,
285    /// A retry budget is durably spent for one key.
286    RetryExhausted,
287    /// A skip became authoritative in the accepting chunk commit.
288    ItemSkipped,
289    /// A known rollback was classified by the fault policy.
290    FaultRollbackCommitted,
291    /// A commit-safe skip committed without rolling back.
292    FaultNoRollbackCommitted,
293}
294
295impl LifecycleEventKind {
296    /// Maps this legacy facade event into telemetry schema version 1.
297    #[must_use]
298    pub const fn telemetry_kind(self) -> crate::TelemetryEventKind {
299        match self {
300            Self::LaunchAccepted => crate::TelemetryEventKind::LaunchAccepted,
301            Self::JobStarting => crate::TelemetryEventKind::JobStarting,
302            Self::StepStarting => crate::TelemetryEventKind::StepStarting,
303            Self::JobStarted => crate::TelemetryEventKind::JobStarted,
304            Self::StepStarted => crate::TelemetryEventKind::StepStarted,
305            Self::JobStopping => crate::TelemetryEventKind::JobStopping,
306            Self::StepStopping => crate::TelemetryEventKind::StepStopping,
307            Self::JobStopped => crate::TelemetryEventKind::JobStopped,
308            Self::StepStopped => crate::TelemetryEventKind::StepStopped,
309            Self::JobCompleted => crate::TelemetryEventKind::JobCompleted,
310            Self::StepCompleted => crate::TelemetryEventKind::StepCompleted,
311            Self::JobFailed => crate::TelemetryEventKind::JobFailed,
312            Self::StepFailed => crate::TelemetryEventKind::StepFailed,
313            Self::JobUnknown => crate::TelemetryEventKind::JobUnknown,
314            Self::StepUnknown => crate::TelemetryEventKind::StepUnknown,
315            Self::ChunkStarted => crate::TelemetryEventKind::ChunkStarted,
316            Self::ChunkCommitted => crate::TelemetryEventKind::ChunkCommitted,
317            Self::ChunkRolledBack => crate::TelemetryEventKind::ChunkRolledBack,
318            Self::ChunkUnknown => crate::TelemetryEventKind::ChunkUnknown,
319            Self::JobBeforeListenerFailed => crate::TelemetryEventKind::JobBeforeListenerFailed,
320            Self::JobAfterListenerFailed => crate::TelemetryEventKind::JobAfterListenerFailed,
321            Self::StepBeforeListenerFailed => crate::TelemetryEventKind::StepBeforeListenerFailed,
322            Self::StepAfterListenerFailed => crate::TelemetryEventKind::StepAfterListenerFailed,
323            Self::RetryReserved => crate::TelemetryEventKind::RetryReserved,
324            Self::RetryBackoffStarted => crate::TelemetryEventKind::RetryBackoffStarted,
325            Self::RetryBackoffCancelled => crate::TelemetryEventKind::RetryBackoffCancelled,
326            Self::RetryExhausted => crate::TelemetryEventKind::RetryExhausted,
327            Self::ItemSkipped => crate::TelemetryEventKind::ItemSkipped,
328            Self::FaultRollbackCommitted => crate::TelemetryEventKind::FaultRollbackCommitted,
329            Self::FaultNoRollbackCommitted => crate::TelemetryEventKind::FaultNoRollbackCommitted,
330        }
331    }
332
333    /// Returns the stable dotted event name.
334    #[must_use]
335    pub const fn as_str(self) -> &'static str {
336        match self {
337            Self::LaunchAccepted => "launch.accepted",
338            Self::JobStarting => "job.starting",
339            Self::StepStarting => "step.starting",
340            Self::JobStarted => "job.started",
341            Self::StepStarted => "step.started",
342            Self::JobStopping => "job.stopping",
343            Self::StepStopping => "step.stopping",
344            Self::JobStopped => "job.stopped",
345            Self::StepStopped => "step.stopped",
346            Self::JobCompleted => "job.completed",
347            Self::StepCompleted => "step.completed",
348            Self::JobFailed => "job.failed",
349            Self::StepFailed => "step.failed",
350            Self::JobUnknown => "job.unknown",
351            Self::StepUnknown => "step.unknown",
352            Self::ChunkStarted => "chunk.started",
353            Self::ChunkCommitted => "chunk.committed",
354            Self::ChunkRolledBack => "chunk.rolled_back",
355            Self::ChunkUnknown => "chunk.unknown",
356            Self::JobBeforeListenerFailed => "job.before_listener.failed",
357            Self::JobAfterListenerFailed => "job.after_listener.failed",
358            Self::StepBeforeListenerFailed => "step.before_listener.failed",
359            Self::StepAfterListenerFailed => "step.after_listener.failed",
360            Self::RetryReserved => "retry.reserved",
361            Self::RetryBackoffStarted => "retry.backoff_started",
362            Self::RetryBackoffCancelled => "retry.backoff_cancelled",
363            Self::RetryExhausted => "retry.exhausted",
364            Self::ItemSkipped => "item.skipped",
365            Self::FaultRollbackCommitted => "fault.rollback_committed",
366            Self::FaultNoRollbackCommitted => "fault.no_rollback_committed",
367        }
368    }
369
370    /// Returns the component associated with the event.
371    #[must_use]
372    pub const fn component(self) -> EventComponent {
373        match self {
374            Self::LaunchAccepted => EventComponent::Launcher,
375            Self::JobStarting
376            | Self::JobStarted
377            | Self::JobStopping
378            | Self::JobStopped
379            | Self::JobCompleted
380            | Self::JobFailed
381            | Self::JobUnknown => EventComponent::Job,
382            Self::StepStarting
383            | Self::StepStarted
384            | Self::StepStopping
385            | Self::StepStopped
386            | Self::StepCompleted
387            | Self::StepFailed
388            | Self::StepUnknown => EventComponent::Step,
389            Self::ChunkStarted
390            | Self::ChunkCommitted
391            | Self::ChunkRolledBack
392            | Self::ChunkUnknown => EventComponent::Chunk,
393            Self::JobBeforeListenerFailed
394            | Self::JobAfterListenerFailed
395            | Self::StepBeforeListenerFailed
396            | Self::StepAfterListenerFailed => EventComponent::Listener,
397            Self::RetryReserved
398            | Self::RetryBackoffStarted
399            | Self::RetryBackoffCancelled
400            | Self::RetryExhausted => EventComponent::Retry,
401            Self::ItemSkipped => EventComponent::Item,
402            Self::FaultRollbackCommitted | Self::FaultNoRollbackCommitted => EventComponent::Fault,
403        }
404    }
405
406    /// Returns the lifecycle status represented by the event, when any.
407    #[must_use]
408    pub const fn status(self) -> Option<BatchStatus> {
409        match self {
410            Self::JobStarting | Self::StepStarting => Some(BatchStatus::Starting),
411            Self::JobStarted | Self::StepStarted => Some(BatchStatus::Started),
412            Self::JobStopping | Self::StepStopping => Some(BatchStatus::Stopping),
413            Self::JobStopped | Self::StepStopped => Some(BatchStatus::Stopped),
414            Self::JobCompleted | Self::StepCompleted => Some(BatchStatus::Completed),
415            Self::JobFailed | Self::StepFailed => Some(BatchStatus::Failed),
416            Self::JobUnknown | Self::StepUnknown => Some(BatchStatus::Unknown),
417            Self::LaunchAccepted
418            | Self::ChunkStarted
419            | Self::ChunkCommitted
420            | Self::ChunkRolledBack
421            | Self::ChunkUnknown
422            | Self::JobBeforeListenerFailed
423            | Self::JobAfterListenerFailed
424            | Self::StepBeforeListenerFailed
425            | Self::StepAfterListenerFailed
426            | Self::RetryReserved
427            | Self::RetryBackoffStarted
428            | Self::RetryBackoffCancelled
429            | Self::RetryExhausted
430            | Self::ItemSkipped
431            | Self::FaultRollbackCommitted
432            | Self::FaultNoRollbackCommitted => None,
433        }
434    }
435
436    /// Returns the stable event severity.
437    #[must_use]
438    pub const fn severity(self) -> EventSeverity {
439        match self {
440            Self::JobFailed
441            | Self::StepFailed
442            | Self::JobUnknown
443            | Self::StepUnknown
444            | Self::ChunkUnknown
445            | Self::JobBeforeListenerFailed
446            | Self::JobAfterListenerFailed
447            | Self::StepBeforeListenerFailed
448            | Self::StepAfterListenerFailed
449            | Self::RetryExhausted => EventSeverity::Error,
450            Self::JobStopping
451            | Self::StepStopping
452            | Self::JobStopped
453            | Self::StepStopped
454            | Self::ChunkRolledBack
455            | Self::RetryReserved
456            | Self::RetryBackoffCancelled
457            | Self::ItemSkipped
458            | Self::FaultRollbackCommitted
459            | Self::FaultNoRollbackCommitted => EventSeverity::Warn,
460            Self::LaunchAccepted
461            | Self::RetryBackoffStarted
462            | Self::JobStarting
463            | Self::StepStarting
464            | Self::JobStarted
465            | Self::StepStarted
466            | Self::JobCompleted
467            | Self::StepCompleted
468            | Self::ChunkStarted
469            | Self::ChunkCommitted => EventSeverity::Info,
470        }
471    }
472}
473
474impl fmt::Display for LifecycleEventKind {
475    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
476        formatter.write_str(self.as_str())
477    }
478}
479
480/// A structured lifecycle event containing only reviewed, bounded fields.
481#[derive(Clone, Debug, Eq, PartialEq)]
482pub struct LifecycleEvent {
483    kind: LifecycleEventKind,
484    correlation: ExecutionCorrelation,
485    failure: Option<FailureSummary>,
486    chunk_sequence: Option<ChunkCount>,
487    fault_phase: Option<FaultPhase>,
488    retry_ordinal: Option<RetryOrdinal>,
489    backoff: Option<Duration>,
490}
491
492impl LifecycleEvent {
493    /// Returns the telemetry schema version carried by this event mapping.
494    #[must_use]
495    pub const fn schema_version(&self) -> u16 {
496        crate::TELEMETRY_SCHEMA_VERSION
497    }
498    pub(crate) const fn new(kind: LifecycleEventKind, correlation: ExecutionCorrelation) -> Self {
499        Self {
500            kind,
501            correlation,
502            failure: None,
503            chunk_sequence: None,
504            fault_phase: None,
505            retry_ordinal: None,
506            backoff: None,
507        }
508    }
509
510    pub(crate) const fn failed(
511        kind: LifecycleEventKind,
512        correlation: ExecutionCorrelation,
513        failure: FailureSummary,
514    ) -> Self {
515        Self {
516            kind,
517            correlation,
518            failure: Some(failure),
519            chunk_sequence: None,
520            fault_phase: None,
521            retry_ordinal: None,
522            backoff: None,
523        }
524    }
525
526    pub(crate) const fn chunk(
527        kind: LifecycleEventKind,
528        correlation: ExecutionCorrelation,
529        sequence: ChunkCount,
530    ) -> Self {
531        Self {
532            kind,
533            correlation,
534            failure: None,
535            chunk_sequence: Some(sequence),
536            fault_phase: None,
537            retry_ordinal: None,
538            backoff: None,
539        }
540    }
541
542    pub(crate) const fn fault(
543        kind: LifecycleEventKind,
544        correlation: ExecutionCorrelation,
545        sequence: ChunkCount,
546        phase: FaultPhase,
547    ) -> Self {
548        Self {
549            kind,
550            correlation,
551            failure: None,
552            chunk_sequence: Some(sequence),
553            fault_phase: Some(phase),
554            retry_ordinal: None,
555            backoff: None,
556        }
557    }
558
559    pub(crate) const fn with_failure(mut self, failure: FailureSummary) -> Self {
560        self.failure = Some(failure);
561        self
562    }
563
564    pub(crate) const fn with_retry_ordinal(mut self, ordinal: RetryOrdinal) -> Self {
565        self.retry_ordinal = Some(ordinal);
566        self
567    }
568
569    pub(crate) const fn with_backoff(mut self, backoff: Duration) -> Self {
570        self.backoff = Some(backoff);
571        self
572    }
573
574    /// Returns the fault phase for a retry, skip, or rollback event.
575    #[must_use]
576    pub const fn fault_phase(&self) -> Option<FaultPhase> {
577        self.fault_phase
578    }
579
580    /// Returns the reserved retry ordinal for a retry event.
581    #[must_use]
582    pub const fn retry_ordinal(&self) -> Option<RetryOrdinal> {
583        self.retry_ordinal
584    }
585
586    /// Returns the deterministic backoff duration for a backoff event.
587    #[must_use]
588    pub const fn backoff(&self) -> Option<Duration> {
589        self.backoff
590    }
591
592    /// Returns the stable event kind.
593    #[must_use]
594    pub const fn kind(&self) -> LifecycleEventKind {
595        self.kind
596    }
597
598    /// Borrows the complete execution correlation.
599    #[must_use]
600    pub const fn correlation(&self) -> &ExecutionCorrelation {
601        &self.correlation
602    }
603
604    /// Returns the redacted failure summary, when present.
605    #[must_use]
606    pub const fn failure(&self) -> Option<FailureSummary> {
607        self.failure
608    }
609
610    /// Returns the chunk-attempt sequence for chunk events.
611    #[must_use]
612    pub const fn chunk_sequence(&self) -> Option<ChunkCount> {
613        self.chunk_sequence
614    }
615
616    /// Produces the reviewed fields suitable for a tracing span or event.
617    #[must_use]
618    pub fn span_fields(&self) -> Vec<DiagnosticField> {
619        let mut fields = vec![
620            DiagnosticField::new("event.name", self.kind.as_str()),
621            DiagnosticField::new("event.severity", self.kind.severity().as_str()),
622            DiagnosticField::new("component", self.kind.component().as_str()),
623            DiagnosticField::new("job.name", self.correlation.job_name().as_str()),
624            DiagnosticField::new(
625                "job.instance.id",
626                self.correlation.job_instance_id().to_string(),
627            ),
628            DiagnosticField::new(
629                "job.execution.id",
630                self.correlation.job_execution_id().to_string(),
631            ),
632            DiagnosticField::new("job.attempt", self.correlation.job_attempt().to_string()),
633            DiagnosticField::new("step.name", self.correlation.step_name().as_str()),
634            DiagnosticField::new(
635                "step.execution.id",
636                self.correlation.step_execution_id().to_string(),
637            ),
638            DiagnosticField::new("step.attempt", self.correlation.step_attempt().to_string()),
639        ];
640        if let Some(status) = self.kind.status() {
641            fields.push(DiagnosticField::new("batch.status", status.to_string()));
642        }
643        if let Some(sequence) = self.chunk_sequence {
644            fields.push(DiagnosticField::new(
645                "chunk.sequence",
646                sequence.get().to_string(),
647            ));
648        }
649        if let Some(phase) = self.fault_phase {
650            fields.push(DiagnosticField::new("fault.phase", phase.as_str()));
651        }
652        if let Some(ordinal) = self.retry_ordinal {
653            fields.push(DiagnosticField::new(
654                "retry.ordinal",
655                ordinal.get().to_string(),
656            ));
657        }
658        if let Some(backoff) = self.backoff {
659            fields.push(DiagnosticField::new(
660                "retry.backoff_ms",
661                u64::try_from(backoff.as_millis())
662                    .unwrap_or(u64::MAX)
663                    .to_string(),
664            ));
665        }
666        if let Some(failure) = self.failure {
667            fields.push(DiagnosticField::new(
668                "failure.category",
669                format!("{:?}", failure.category()),
670            ));
671            fields.push(DiagnosticField::new(
672                "failure.id",
673                failure.failure_id().to_string(),
674            ));
675        }
676        fields
677    }
678
679    /// Produces a bounded metric label set.
680    ///
681    /// Identifiers, names, parameters, contexts, records, and error text are
682    /// intentionally absent.
683    #[must_use]
684    pub fn metric_labels(&self) -> Vec<MetricLabel> {
685        let mut labels = vec![
686            MetricLabel::new("event", self.kind.as_str()),
687            MetricLabel::new("component", self.kind.component().as_str()),
688        ];
689        if let Some(status) = self.kind.status() {
690            labels.push(MetricLabel::new("status", status.to_string()));
691        }
692        if let Some(phase) = self.fault_phase {
693            labels.push(MetricLabel::new("fault_phase", phase.as_str()));
694        }
695        labels
696    }
697}
698
699impl fmt::Display for LifecycleEvent {
700    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
701        write!(
702            formatter,
703            "event={} severity={} job={} job_instance_id={} job_execution_id={} \
704             job_attempt={} step={} step_execution_id={} step_attempt={}",
705            self.kind,
706            self.kind.severity(),
707            self.correlation.job_name(),
708            self.correlation.job_instance_id(),
709            self.correlation.job_execution_id(),
710            self.correlation.job_attempt(),
711            self.correlation.step_name(),
712            self.correlation.step_execution_id(),
713            self.correlation.step_attempt(),
714        )?;
715        if let Some(status) = self.kind.status() {
716            write!(formatter, " status={status}")?;
717        }
718        if let Some(sequence) = self.chunk_sequence {
719            write!(formatter, " chunk_sequence={}", sequence.get())?;
720        }
721        if let Some(phase) = self.fault_phase {
722            write!(formatter, " fault_phase={phase}")?;
723        }
724        if let Some(ordinal) = self.retry_ordinal {
725            write!(formatter, " retry_ordinal={}", ordinal.get())?;
726        }
727        if let Some(backoff) = self.backoff {
728            write!(formatter, " backoff_ms={}", backoff.as_millis())?;
729        }
730        if let Some(failure) = self.failure {
731            write!(
732                formatter,
733                " failure_category={:?} failure_id={}",
734                failure.category(),
735                failure.failure_id()
736            )?;
737        }
738        Ok(())
739    }
740}
741
742/// A reviewed key/value field suitable for structured logs or spans.
743#[derive(Clone, Debug, Eq, PartialEq)]
744pub struct DiagnosticField {
745    key: &'static str,
746    value: String,
747}
748
749impl DiagnosticField {
750    pub(crate) fn new(key: &'static str, value: impl Into<String>) -> Self {
751        Self {
752            key,
753            value: value.into(),
754        }
755    }
756
757    /// Returns the stable field key.
758    #[must_use]
759    pub const fn key(&self) -> &'static str {
760        self.key
761    }
762
763    /// Returns the reviewed field value.
764    #[must_use]
765    pub fn value(&self) -> &str {
766        &self.value
767    }
768}
769
770impl fmt::Display for DiagnosticField {
771    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
772        write!(formatter, "{}={}", self.key, self.value)
773    }
774}
775
776/// A bounded, framework-owned metric label.
777#[derive(Clone, Debug, Eq, PartialEq)]
778pub struct MetricLabel {
779    key: &'static str,
780    value: String,
781}
782
783impl MetricLabel {
784    pub(crate) fn new(key: &'static str, value: impl Into<String>) -> Self {
785        Self {
786            key,
787            value: value.into(),
788        }
789    }
790
791    /// Returns the stable label key.
792    #[must_use]
793    pub const fn key(&self) -> &'static str {
794        self.key
795    }
796
797    /// Returns the bounded framework-owned value.
798    #[must_use]
799    pub fn value(&self) -> &str {
800        &self.value
801    }
802
803    pub(crate) fn replace_value(&mut self, value: &'static str) {
804        value.clone_into(&mut self.value);
805    }
806}
807
808impl fmt::Display for MetricLabel {
809    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
810        write!(formatter, "{}={}", self.key, self.value)
811    }
812}
813
814/// Receives committed lifecycle observations.
815///
816/// Sink failures and panics are isolated by [`crate::JobLauncher`] and cannot
817/// change execution correctness.
818pub trait LifecycleEventSink: Send + Sync {
819    /// Emits one event after the corresponding metadata commit.
820    fn emit(&self, event: &LifecycleEvent);
821}