1#![forbid(unsafe_code)]
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use xbp_delivery::{DeliveryEvent, DeliveryEventKind};
6
7#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize)]
8pub struct Hours(pub f64);
9
10#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize)]
11pub struct Days(pub f64);
12
13#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize)]
14pub struct RatePerDay(pub f64);
15
16#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize)]
17pub struct Probability(pub f64);
18
19#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize)]
20pub struct Coverage(pub f64);
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum AnalyticsError {
24 EmptySample,
25 InvalidProbability,
26 InvalidWindow,
27 NegativeDuration,
28 NonFiniteValue,
29 InvalidEvidenceCoverage,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum AnalyticsSubjectKind {
35 Repository,
36 Service,
37 Project,
38 Initiative,
39 Portfolio,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
43pub struct AnalyticsSubject {
44 pub kind: AnalyticsSubjectKind,
45 pub id: String,
46}
47
48impl AnalyticsSubject {
49 pub fn new(kind: AnalyticsSubjectKind, id: impl Into<String>) -> Self {
50 Self {
51 kind,
52 id: id.into(),
53 }
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum TrendDirection {
60 Improving,
61 Stable,
62 Declining,
63 Volatile,
64 StructuralBreak,
65 Unknown,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
69pub struct DoraTrend {
70 pub current: Option<f64>,
71 pub previous: Option<f64>,
72 pub delta_absolute: Option<f64>,
73 pub delta_relative: Option<f64>,
74 pub trend: TrendDirection,
75 pub confidence: f64,
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct DoraComparison {
80 pub subject: AnalyticsSubject,
81 pub deployment_frequency: DoraTrend,
82 pub lead_time: DoraTrend,
83 pub change_failure_rate: DoraTrend,
84 pub recovery_time: DoraTrend,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum LittleLawStatus {
90 Consistent,
91 Divergent,
92 Insufficient,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
96pub struct LittleLawDiagnostic {
97 pub wip: usize,
98 pub throughput_per_day: f64,
99 pub cycle_time_hours: f64,
100 pub expected_wip: f64,
101 pub relative_error: f64,
102 pub status: LittleLawStatus,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum WipBand {
108 Normal,
109 Elevated,
110 Overloaded,
111 Unknown,
112}
113
114pub fn classify_wip_band(wip: usize, throughput_per_day: f64, cycle_time_hours: f64) -> WipBand {
115 if throughput_per_day <= 0.0 || cycle_time_hours <= 0.0 {
116 return WipBand::Unknown;
117 }
118 let expected = throughput_per_day * cycle_time_hours / 24.0;
119 if expected <= 0.0 {
120 WipBand::Unknown
121 } else {
122 let ratio = wip as f64 / expected;
123 if ratio <= 1.25 {
124 WipBand::Normal
125 } else if ratio <= 2.0 {
126 WipBand::Elevated
127 } else {
128 WipBand::Overloaded
129 }
130 }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
134pub struct WipAgeAssessment {
135 pub age_hours: f64,
136 pub historical_p85_hours: Option<f64>,
137 pub percentile: Option<f64>,
138 pub aging: bool,
139}
140
141pub fn assess_wip_age(age_hours: f64, historical_cycle_times_hours: &[f64]) -> WipAgeAssessment {
142 let mut sorted = historical_cycle_times_hours
143 .iter()
144 .copied()
145 .filter(|value| value.is_finite() && *value >= 0.0)
146 .collect::<Vec<_>>();
147 sorted.sort_by(f64::total_cmp);
148 let historical_p85_hours = if sorted.is_empty() {
149 None
150 } else {
151 let index = ((sorted.len() as f64 * 0.85).ceil() as usize)
152 .saturating_sub(1)
153 .min(sorted.len() - 1);
154 Some(sorted[index])
155 };
156 let percentile = if sorted.is_empty() {
157 None
158 } else {
159 Some(
160 sorted.iter().filter(|value| **value <= age_hours).count() as f64 / sorted.len() as f64,
161 )
162 };
163 WipAgeAssessment {
164 age_hours,
165 historical_p85_hours,
166 percentile,
167 aging: historical_p85_hours.is_some_and(|value| age_hours > value),
168 }
169}
170
171pub fn little_law_diagnostic(
172 wip: usize,
173 throughput_per_day: f64,
174 cycle_time_hours: f64,
175) -> LittleLawDiagnostic {
176 let expected_wip = throughput_per_day * cycle_time_hours / 24.0;
177 let relative_error = if expected_wip == 0.0 {
178 if wip == 0 {
179 0.0
180 } else {
181 1.0
182 }
183 } else {
184 (wip as f64 - expected_wip).abs() / expected_wip.abs()
185 };
186 LittleLawDiagnostic {
187 wip,
188 throughput_per_day,
189 cycle_time_hours,
190 expected_wip,
191 relative_error,
192 status: if throughput_per_day <= 0.0 || cycle_time_hours <= 0.0 {
193 LittleLawStatus::Insufficient
194 } else if relative_error <= 0.25 {
195 LittleLawStatus::Consistent
196 } else {
197 LittleLawStatus::Divergent
198 },
199 }
200}
201
202pub fn dora_comparison(current: &DoraMetrics, previous: &DoraMetrics) -> DoraComparison {
203 let subject = current
204 .subject
205 .clone()
206 .unwrap_or_else(|| AnalyticsSubject::new(AnalyticsSubjectKind::Project, "unknown"));
207 DoraComparison {
208 subject,
209 deployment_frequency: dora_trend(
210 current.deployment_frequency_per_week,
211 previous.deployment_frequency_per_week,
212 false,
213 ),
214 lead_time: dora_trend(
215 current.lead_time_for_changes_hours,
216 previous.lead_time_for_changes_hours,
217 true,
218 ),
219 change_failure_rate: dora_trend(
220 current.change_failure_rate,
221 previous.change_failure_rate,
222 true,
223 ),
224 recovery_time: dora_trend(
225 current.failed_deployment_recovery_hours,
226 previous.failed_deployment_recovery_hours,
227 true,
228 ),
229 }
230}
231
232fn dora_trend(current: Option<f64>, previous: Option<f64>, lower_is_better: bool) -> DoraTrend {
233 let delta_absolute = current
234 .zip(previous)
235 .map(|(current, previous)| current - previous);
236 let delta_relative = current
237 .zip(previous)
238 .filter(|(_, previous)| *previous != 0.0)
239 .map(|(current, previous)| (current - previous) / previous.abs());
240 let trend = match delta_relative {
241 None => TrendDirection::Unknown,
242 Some(delta) if delta.abs() < 0.10 => TrendDirection::Stable,
243 Some(delta) if (delta < 0.0) == lower_is_better => TrendDirection::Improving,
244 Some(_) => TrendDirection::Declining,
245 };
246 DoraTrend {
247 current,
248 previous,
249 delta_absolute,
250 delta_relative,
251 trend,
252 confidence: if current.is_some() && previous.is_some() {
253 1.0
254 } else {
255 0.0
256 },
257 }
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
261pub struct TimeWindow {
262 pub start: DateTime<Utc>,
263 pub end: DateTime<Utc>,
264}
265
266impl TimeWindow {
267 pub fn new(start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
268 Self { start, end }
269 }
270
271 pub fn days(&self) -> Result<f64, AnalyticsError> {
272 let seconds = (self.end - self.start).num_seconds();
273 if seconds <= 0 {
274 return Err(AnalyticsError::InvalidWindow);
275 }
276 Ok(seconds as f64 / 86_400.0)
277 }
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
281pub enum PercentileMethod {
282 NearestRank,
283}
284
285#[derive(Debug, Clone, PartialEq, Serialize)]
286pub struct DistributionSummary {
287 pub sample_count: usize,
288 pub method: PercentileMethod,
289 pub min: Option<f64>,
290 pub max: Option<f64>,
291 pub mean: Option<f64>,
292 pub median: Option<f64>,
293 pub p50: Option<f64>,
294 pub p75: Option<f64>,
295 pub p85: Option<f64>,
296 pub p90: Option<f64>,
297 pub p95: Option<f64>,
298 pub p99: Option<f64>,
299 pub stddev: Option<f64>,
300 pub mad: Option<f64>,
301 pub iqr: Option<f64>,
302}
303
304impl DistributionSummary {
305 pub fn from_samples(values: &[f64]) -> Result<Self, AnalyticsError> {
306 validate_values(values)?;
307 if values.is_empty() {
308 return Ok(Self {
309 sample_count: 0,
310 method: PercentileMethod::NearestRank,
311 min: None,
312 max: None,
313 mean: None,
314 median: None,
315 p50: None,
316 p75: None,
317 p85: None,
318 p90: None,
319 p95: None,
320 p99: None,
321 stddev: None,
322 mad: None,
323 iqr: None,
324 });
325 }
326 Ok(Self {
327 sample_count: values.len(),
328 method: PercentileMethod::NearestRank,
329 min: Some(values.iter().copied().fold(f64::INFINITY, f64::min)),
330 max: Some(values.iter().copied().fold(f64::NEG_INFINITY, f64::max)),
331 mean: Some(mean(values)?),
332 median: Some(percentile(values, 0.5)?),
333 p50: Some(percentile(values, 0.5)?),
334 p75: Some(percentile(values, 0.75)?),
335 p85: Some(percentile(values, 0.85)?),
336 p90: Some(percentile(values, 0.90)?),
337 p95: Some(percentile(values, 0.95)?),
338 p99: Some(percentile(values, 0.99)?),
339 stddev: Some(variance(values)?.sqrt()),
340 mad: Some(mad(values)?),
341 iqr: Some(percentile(values, 0.75)? - percentile(values, 0.25)?),
342 })
343 }
344}
345
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
347#[serde(rename_all = "snake_case")]
348pub enum CoverageAvailability {
349 Available,
350 #[default]
351 Unavailable,
352}
353
354#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
355pub struct EvidenceCoverage {
356 pub eligible: usize,
357 pub observed: usize,
358 pub sufficient: usize,
359 pub ratio: f64,
360 #[serde(default)]
361 pub availability: CoverageAvailability,
362}
363
364impl EvidenceCoverage {
365 pub fn new(eligible: usize, observed: usize, sufficient: usize) -> Self {
366 Self::try_new(eligible, observed, sufficient)
367 .expect("evidence coverage denominators must be ordered")
368 }
369
370 pub fn try_new(
371 eligible: usize,
372 observed: usize,
373 sufficient: usize,
374 ) -> Result<Self, AnalyticsError> {
375 if observed > eligible || sufficient > observed {
376 return Err(AnalyticsError::InvalidEvidenceCoverage);
377 }
378 Ok(Self {
379 eligible,
380 observed,
381 sufficient,
382 ratio: if eligible == 0 {
383 0.0
384 } else {
385 sufficient as f64 / eligible as f64
386 },
387 availability: if eligible > 0 && sufficient > 0 {
388 CoverageAvailability::Available
389 } else {
390 CoverageAvailability::Unavailable
391 },
392 })
393 }
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
397#[serde(rename_all = "snake_case")]
398pub enum DataQualityWarning {
399 InsufficientHistory,
400 IncompleteContributorBinding,
401 MissingDeploymentBindings,
402 StaleProviderData,
403 PartialStateHistory,
404 LowDeploymentCorrelationCoverage,
405}
406
407#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
408pub struct DataQuality {
409 pub coverage: EvidenceCoverage,
410 pub freshness_seconds: Option<i64>,
411 pub effective_sample_size: f64,
412 pub missing_fields: Vec<String>,
413 pub warnings: Vec<DataQualityWarning>,
414}
415
416impl DataQuality {
417 pub fn new(
418 coverage: EvidenceCoverage,
419 freshness_seconds: Option<i64>,
420 effective_sample_size: f64,
421 missing_fields: Vec<String>,
422 warnings: Vec<DataQualityWarning>,
423 ) -> Self {
424 Self {
425 coverage,
426 freshness_seconds,
427 effective_sample_size,
428 missing_fields,
429 warnings,
430 }
431 }
432}
433
434#[derive(Debug, Clone, PartialEq)]
435pub struct FlowSamples<'a> {
436 pub lead_times_hours: &'a [f64],
437 pub cycle_times_hours: &'a [f64],
438 pub active_times_hours: &'a [f64],
439 pub blocked_times_hours: &'a [f64],
440 pub review_wait_times_hours: &'a [f64],
441 pub deployment_wait_times_hours: &'a [f64],
442}
443
444#[derive(Debug, Clone, PartialEq, Serialize)]
445pub struct FlowMetrics {
446 pub window: TimeWindow,
447 pub completed_count: usize,
448 pub eligible_completed_count: usize,
449 pub observed_completed_count: usize,
450 pub valid_completed_count: usize,
451 pub arrival_count: usize,
452 pub wip: usize,
453 pub blocked_wip: usize,
454 pub throughput_per_day: f64,
455 pub arrival_rate_per_day: f64,
456 pub queue_growth_per_day: f64,
457 pub lead_time_hours: DistributionSummary,
458 pub cycle_time_hours: DistributionSummary,
459 pub active_time_hours: DistributionSummary,
460 pub blocked_time_hours: DistributionSummary,
461 pub review_wait_hours: DistributionSummary,
462 pub deployment_wait_hours: DistributionSummary,
463 pub flow_efficiency: Option<f64>,
464 pub sample_count: usize,
465 pub coverage: EvidenceCoverage,
466}
467
468impl FlowMetrics {
469 pub const SCHEMA_V1: &'static str = "xbp.analytics.flow/v1";
470 pub const SCHEMA_V2: &'static str = "xbp.analytics.flow/v2";
471
472 pub fn from_samples(
473 window: TimeWindow,
474 completed_count: usize,
475 arrival_count: usize,
476 wip: usize,
477 samples: FlowSamples<'_>,
478 ) -> Result<Self, AnalyticsError> {
479 Self::from_samples_with_coverage(
480 window,
481 completed_count,
482 completed_count,
483 completed_count,
484 arrival_count,
485 wip,
486 0,
487 samples,
488 )
489 }
490
491 #[allow(clippy::too_many_arguments)]
492 pub fn from_samples_with_coverage(
493 window: TimeWindow,
494 eligible_completed_count: usize,
495 observed_completed_count: usize,
496 valid_completed_count: usize,
497 arrival_count: usize,
498 wip: usize,
499 blocked_wip: usize,
500 samples: FlowSamples<'_>,
501 ) -> Result<Self, AnalyticsError> {
502 reject_negative_durations(&[
503 samples.lead_times_hours,
504 samples.cycle_times_hours,
505 samples.active_times_hours,
506 samples.blocked_times_hours,
507 samples.review_wait_times_hours,
508 samples.deployment_wait_times_hours,
509 ])?;
510 let window_days = window.days()?;
511 let cycle = DistributionSummary::from_samples(samples.cycle_times_hours)?;
512 let active = DistributionSummary::from_samples(samples.active_times_hours)?;
513 let flow_efficiency = match (active.mean, cycle.mean) {
514 (Some(active), Some(cycle)) if cycle > 0.0 => Some(active / cycle),
515 _ => None,
516 };
517 let coverage = EvidenceCoverage::try_new(
518 eligible_completed_count,
519 observed_completed_count,
520 valid_completed_count,
521 )?;
522 Ok(Self {
523 window,
524 completed_count: valid_completed_count,
525 eligible_completed_count,
526 observed_completed_count,
527 valid_completed_count,
528 arrival_count,
529 wip,
530 blocked_wip,
531 throughput_per_day: valid_completed_count as f64 / window_days,
532 arrival_rate_per_day: arrival_count as f64 / window_days,
533 queue_growth_per_day: (arrival_count as f64 - valid_completed_count as f64)
534 / window_days,
535 lead_time_hours: DistributionSummary::from_samples(samples.lead_times_hours)?,
536 cycle_time_hours: cycle,
537 active_time_hours: active,
538 blocked_time_hours: DistributionSummary::from_samples(samples.blocked_times_hours)?,
539 review_wait_hours: DistributionSummary::from_samples(samples.review_wait_times_hours)?,
540 deployment_wait_hours: DistributionSummary::from_samples(
541 samples.deployment_wait_times_hours,
542 )?,
543 flow_efficiency,
544 sample_count: samples.lead_times_hours.len(),
545 coverage,
546 })
547 }
548}
549
550#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
551pub struct FlowMetricsV1 {
552 pub throughput: f64,
553 pub arrival_rate: f64,
554 pub wip: usize,
555 pub queue_growth: f64,
556 pub lead_time: Option<f64>,
557 pub cycle_time: Option<f64>,
558 pub active_time: Option<f64>,
559 pub blocked_time: Option<f64>,
560 pub review_wait: Option<f64>,
561 pub deployment_wait: Option<f64>,
562 pub flow_efficiency: Option<f64>,
563 pub sample_count: usize,
564}
565
566impl FlowMetrics {
567 pub fn to_v1(&self) -> FlowMetricsV1 {
568 FlowMetricsV1 {
569 throughput: self.throughput_per_day,
570 arrival_rate: self.arrival_rate_per_day,
571 wip: self.wip,
572 queue_growth: self.queue_growth_per_day,
573 lead_time: self.lead_time_hours.mean,
574 cycle_time: self.cycle_time_hours.mean,
575 active_time: self.active_time_hours.mean,
576 blocked_time: self.blocked_time_hours.mean,
577 review_wait: self.review_wait_hours.mean,
578 deployment_wait: self.deployment_wait_hours.mean,
579 flow_efficiency: self.flow_efficiency,
580 sample_count: self.sample_count,
581 }
582 }
583}
584
585pub type FlowProjection = FlowMetrics;
586
587impl FlowMetrics {
588 pub fn from_events(events: &[DeliveryEvent], window: TimeWindow) -> Result<Self, String> {
589 use std::collections::{BTreeMap, BTreeSet};
590
591 window
592 .days()
593 .map_err(|error| format!("invalid flow window: {error:?}"))?;
594 let mut histories: BTreeMap<String, Vec<&DeliveryEvent>> = BTreeMap::new();
595 for event in events
596 .iter()
597 .filter(|event| event.occurred_at <= window.end)
598 {
599 let Some(work_item_id) =
600 event
601 .work_item_id
602 .as_ref()
603 .map(|id| id.0.clone())
604 .or_else(|| {
605 event
606 .attributes
607 .get("work_item_id")
608 .and_then(|value| value.as_str())
609 .map(str::to_string)
610 })
611 else {
612 continue;
613 };
614 histories.entry(work_item_id).or_default().push(event);
615 }
616 for history in histories.values_mut() {
617 history.sort_by_key(|event| (event.occurred_at, event.id.clone()));
618 }
619
620 let completed_ids = histories
621 .iter()
622 .filter(|(_, history)| {
623 history.iter().any(|event| {
624 event.kind == DeliveryEventKind::WorkItemCompleted
625 && event.occurred_at >= window.start
626 && event.occurred_at <= window.end
627 })
628 })
629 .map(|(id, _)| id.clone())
630 .collect::<BTreeSet<_>>();
631 let arrival_count = histories
632 .values()
633 .filter(|history| {
634 history.iter().any(|event| {
635 event.kind == DeliveryEventKind::WorkItemCreated
636 && event.occurred_at >= window.start
637 && event.occurred_at <= window.end
638 })
639 })
640 .count();
641
642 let mut lead_times = Vec::new();
643 let mut cycle_times = Vec::new();
644 let mut blocked_times = Vec::new();
645 let mut observed_completed_count = 0;
646 let mut blocked_wip = 0;
647
648 for (work_item_id, history) in &histories {
649 let created = history
650 .iter()
651 .find(|event| event.kind == DeliveryEventKind::WorkItemCreated)
652 .map(|event| event.occurred_at);
653 let started = history
654 .iter()
655 .find(|event| event.kind == DeliveryEventKind::WorkItemStarted)
656 .map(|event| event.occurred_at);
657 let completed = history
658 .iter()
659 .find(|event| event.kind == DeliveryEventKind::WorkItemCompleted)
660 .map(|event| event.occurred_at);
661
662 let mut is_blocked = false;
663 for event in history {
664 match event.kind {
665 DeliveryEventKind::WorkItemBlocked if !is_blocked => is_blocked = true,
666 DeliveryEventKind::WorkItemUnblocked if is_blocked => is_blocked = false,
667 DeliveryEventKind::WorkItemBlocked => {
668 return Err(format!(
669 "duplicate block transition for work item {}",
670 event
671 .work_item_id
672 .as_ref()
673 .map(|id| id.0.as_str())
674 .unwrap_or("unknown")
675 ));
676 }
677 DeliveryEventKind::WorkItemUnblocked => {
678 return Err("unmatched unblock transition in delivery evidence".into());
679 }
680 _ => {}
681 }
682 }
683 if completed.is_none()
684 && created.is_some_and(|occurred_at| occurred_at <= window.end)
685 && is_blocked
686 {
687 blocked_wip += 1;
688 }
689
690 if !completed_ids.contains(work_item_id) {
691 continue;
692 }
693 let Some(created) = created else {
694 continue;
695 };
696 let Some(started) = started else {
697 continue;
698 };
699 let Some(completed) = completed else {
700 continue;
701 };
702 if started < created || completed < started {
703 return Err("negative lifecycle duration in delivery evidence".into());
704 }
705 let (mut blocked_at, mut blocked_hours) = (None, 0.0);
706 for event in history {
707 match event.kind {
708 DeliveryEventKind::WorkItemBlocked => blocked_at = Some(event.occurred_at),
709 DeliveryEventKind::WorkItemUnblocked => {
710 let Some(blocked_at_value) = blocked_at.take() else {
711 return Err("unmatched unblock transition in delivery evidence".into());
712 };
713 let hours =
714 (event.occurred_at - blocked_at_value).num_seconds() as f64 / 3600.0;
715 if hours < 0.0 {
716 return Err("negative blocked duration in delivery evidence".into());
717 }
718 blocked_hours += hours;
719 }
720 _ => {}
721 }
722 }
723 if blocked_at.is_some() {
724 return Err("unmatched block transition in delivery evidence".into());
725 }
726 lead_times.push((completed - created).num_seconds() as f64 / 3600.0);
727 cycle_times.push((completed - started).num_seconds() as f64 / 3600.0);
728 blocked_times.push(blocked_hours);
729 observed_completed_count += 1;
730 }
731
732 Self::from_samples_with_coverage(
733 window.clone(),
734 completed_ids.len(),
735 observed_completed_count,
736 observed_completed_count,
737 arrival_count,
738 histories
739 .values()
740 .filter(|history| {
741 let created = history
742 .iter()
743 .find(|event| event.kind == DeliveryEventKind::WorkItemCreated)
744 .map(|event| event.occurred_at);
745 let completed = history
746 .iter()
747 .find(|event| event.kind == DeliveryEventKind::WorkItemCompleted)
748 .map(|event| event.occurred_at);
749 created.is_some_and(|occurred_at| occurred_at <= window.end)
750 && completed.is_none()
751 })
752 .count(),
753 blocked_wip,
754 FlowSamples {
755 lead_times_hours: &lead_times,
756 cycle_times_hours: &cycle_times,
757 active_times_hours: &[],
758 blocked_times_hours: &blocked_times,
759 review_wait_times_hours: &[],
760 deployment_wait_times_hours: &[],
761 },
762 )
763 .map_err(|error| format!("flow projection failed: {error:?}"))
764 }
765}
766
767pub fn count(values: &[f64]) -> usize {
768 values.len()
769}
770
771pub fn mean(values: &[f64]) -> Result<f64, AnalyticsError> {
772 validate_values(values)?;
773 if values.is_empty() {
774 return Err(AnalyticsError::EmptySample);
775 }
776 Ok(values.iter().sum::<f64>() / values.len() as f64)
777}
778
779pub fn median(values: &[f64]) -> Result<f64, AnalyticsError> {
780 percentile(values, 0.5)
781}
782
783pub fn percentile(values: &[f64], probability: f64) -> Result<f64, AnalyticsError> {
784 validate_values(values)?;
785 if values.is_empty() {
786 return Err(AnalyticsError::EmptySample);
787 }
788 if !(0.0..=1.0).contains(&probability) {
789 return Err(AnalyticsError::InvalidProbability);
790 }
791 let mut sorted = values.to_vec();
792 sorted.sort_by(f64::total_cmp);
793 let rank = (probability * sorted.len() as f64).ceil().max(1.0) as usize;
794 Ok(sorted[rank - 1])
795}
796
797pub fn variance(values: &[f64]) -> Result<f64, AnalyticsError> {
798 let average = mean(values)?;
799 Ok(values
800 .iter()
801 .map(|value| (value - average).powi(2))
802 .sum::<f64>()
803 / values.len() as f64)
804}
805
806pub fn standard_deviation(values: &[f64]) -> Result<f64, AnalyticsError> {
807 Ok(variance(values)?.sqrt())
808}
809
810pub fn iqr(values: &[f64]) -> Result<f64, AnalyticsError> {
811 Ok(percentile(values, 0.75)? - percentile(values, 0.25)?)
812}
813
814pub fn mad(values: &[f64]) -> Result<f64, AnalyticsError> {
815 let center = median(values)?;
816 let deviations: Vec<f64> = values.iter().map(|value| (value - center).abs()).collect();
817 median(&deviations)
818}
819
820pub fn histogram(values: &[f64], bins: usize) -> Result<Vec<usize>, AnalyticsError> {
821 validate_values(values)?;
822 if bins == 0 {
823 return Ok(Vec::new());
824 }
825 if values.is_empty() {
826 return Ok(vec![0; bins]);
827 }
828 let min = values.iter().copied().fold(f64::INFINITY, f64::min);
829 let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
830 if min == max {
831 let mut result = vec![0; bins];
832 result[0] = values.len();
833 return Ok(result);
834 }
835 let width = (max - min) / bins as f64;
836 let mut result = vec![0; bins];
837 for value in values {
838 let index = (((value - min) / width).floor() as usize).min(bins - 1);
839 result[index] += 1;
840 }
841 Ok(result)
842}
843
844pub fn empirical_cdf(values: &[f64], threshold: f64) -> Result<f64, AnalyticsError> {
845 validate_values(values)?;
846 if values.is_empty() {
847 return Err(AnalyticsError::EmptySample);
848 }
849 if !threshold.is_finite() {
850 return Err(AnalyticsError::NonFiniteValue);
851 }
852 Ok(values.iter().filter(|value| **value <= threshold).count() as f64 / values.len() as f64)
853}
854
855fn validate_values(values: &[f64]) -> Result<(), AnalyticsError> {
856 if values.iter().any(|value| !value.is_finite()) {
857 return Err(AnalyticsError::NonFiniteValue);
858 }
859 Ok(())
860}
861
862fn reject_negative_durations(samples: &[&[f64]]) -> Result<(), AnalyticsError> {
863 for sample in samples {
864 validate_values(sample)?;
865 if sample.iter().any(|value| *value < 0.0) {
866 return Err(AnalyticsError::NegativeDuration);
867 }
868 }
869 Ok(())
870}
871
872#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
873#[serde(rename_all = "snake_case")]
874pub enum MetricAvailability {
875 Available,
876 Unavailable,
877}
878
879#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
880#[serde(rename_all = "snake_case")]
881pub enum MetricEvidenceStatus {
882 #[default]
883 NoEvidence,
884 InsufficientEvidence,
885 Available,
886}
887
888#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
889pub struct DoraMetricEvidence {
890 pub eligible: usize,
891 pub observed: usize,
892 pub sufficient: usize,
893 pub availability: MetricAvailability,
894}
895
896impl DoraMetricEvidence {
897 pub fn from_counts(eligible: usize, observed: usize, sufficient: usize) -> Self {
898 Self::with_availability(eligible, observed, sufficient, sufficient > 0)
899 }
900
901 pub fn status(&self) -> MetricEvidenceStatus {
902 if self.eligible == 0 {
903 MetricEvidenceStatus::NoEvidence
904 } else if self.sufficient < self.eligible {
905 MetricEvidenceStatus::InsufficientEvidence
906 } else {
907 MetricEvidenceStatus::Available
908 }
909 }
910
911 fn new(eligible: usize, observed: usize, sufficient: usize) -> Self {
912 Self::from_counts(eligible, observed, sufficient)
913 }
914
915 fn with_availability(
916 eligible: usize,
917 observed: usize,
918 sufficient: usize,
919 available: bool,
920 ) -> Self {
921 Self {
922 eligible,
923 observed,
924 sufficient,
925 availability: if available {
926 MetricAvailability::Available
927 } else {
928 MetricAvailability::Unavailable
929 },
930 }
931 }
932}
933
934#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
935pub struct DoraMetricsV1 {
936 pub deployment_frequency_per_week: f64,
937 pub lead_time_for_changes_hours: Option<f64>,
938 pub change_failure_rate: f64,
939 pub failed_deployment_recovery_hours: Option<f64>,
940 pub sample_count: usize,
941 pub window_start: DateTime<Utc>,
942 pub window_end: DateTime<Utc>,
943}
944
945#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
946pub struct DoraMetrics {
947 #[serde(default)]
948 pub subject: Option<AnalyticsSubject>,
949 pub deployment_frequency_per_week: Option<f64>,
950 pub lead_time_for_changes_hours: Option<f64>,
951 pub change_failure_rate: Option<f64>,
952 pub failed_deployment_recovery_hours: Option<f64>,
953 pub deployment_frequency_evidence: DoraMetricEvidence,
954 pub lead_time_evidence: DoraMetricEvidence,
955 pub change_failure_rate_evidence: DoraMetricEvidence,
956 pub recovery_evidence: DoraMetricEvidence,
957 #[serde(default)]
958 pub deployment_frequency_status: MetricEvidenceStatus,
959 #[serde(default)]
960 pub lead_time_status: MetricEvidenceStatus,
961 #[serde(default)]
962 pub change_failure_rate_status: MetricEvidenceStatus,
963 #[serde(default)]
964 pub recovery_status: MetricEvidenceStatus,
965 #[serde(skip)]
966 pub sample_count: usize,
967 pub window_start: DateTime<Utc>,
968 pub window_end: DateTime<Utc>,
969}
970
971impl DoraMetrics {
972 pub const SCHEMA_V1: &'static str = "xbp.analytics.dora/v1";
973 pub const SCHEMA_V2: &'static str = "xbp.analytics.dora/v2";
974
975 pub fn to_v1(&self) -> DoraMetricsV1 {
976 DoraMetricsV1 {
977 deployment_frequency_per_week: self.deployment_frequency_per_week.unwrap_or(0.0),
978 lead_time_for_changes_hours: self.lead_time_for_changes_hours,
979 change_failure_rate: self.change_failure_rate.unwrap_or(0.0),
980 failed_deployment_recovery_hours: self.failed_deployment_recovery_hours,
981 sample_count: self.sample_count,
982 window_start: self.window_start,
983 window_end: self.window_end,
984 }
985 }
986
987 pub fn from_events(
988 events: &[DeliveryEvent],
989 window_start: DateTime<Utc>,
990 window_end: DateTime<Utc>,
991 ) -> Self {
992 let in_window = events
993 .iter()
994 .filter(|event| event.occurred_at >= window_start && event.occurred_at <= window_end)
995 .collect::<Vec<_>>();
996 let production_deployments = in_window
997 .iter()
998 .filter(|event| {
999 matches!(
1000 event.kind,
1001 DeliveryEventKind::DeploymentSucceeded | DeliveryEventKind::DeploymentFailed
1002 ) && event
1003 .attributes
1004 .get("environment")
1005 .and_then(|value| value.as_str())
1006 .is_some_and(|environment| environment.eq_ignore_ascii_case("production"))
1007 })
1008 .collect::<Vec<_>>();
1009 let successful_deployments = production_deployments
1010 .iter()
1011 .filter(|event| {
1012 event.kind == DeliveryEventKind::DeploymentSucceeded
1013 && event
1014 .attributes
1015 .get("outcome")
1016 .and_then(|value| value.as_str())
1017 .map(|outcome| outcome.eq_ignore_ascii_case("succeeded"))
1018 .unwrap_or(true)
1019 })
1020 .collect::<Vec<_>>();
1021 let failed_deployments = production_deployments
1022 .iter()
1023 .filter(|event| has_failed_deployment_outcome(event))
1024 .collect::<Vec<_>>();
1025 let deployment_count = production_deployments.len();
1026 let commits = events
1027 .iter()
1028 .filter(|event| event.kind == DeliveryEventKind::CommitCreated)
1029 .collect::<Vec<_>>();
1030 let correlated_deployments = successful_deployments
1031 .iter()
1032 .filter(|deployment| {
1033 deployment
1034 .attributes
1035 .get("commit_ids")
1036 .and_then(|value| value.as_array())
1037 .is_some_and(|commit_ids| {
1038 commits.iter().any(|commit| {
1039 commit_ids.iter().any(|commit_id| {
1040 commit_id.as_str() == Some(commit.id.as_str())
1041 || commit_id.as_str() == Some(commit.source_event_id.as_str())
1042 })
1043 })
1044 })
1045 })
1046 .count();
1047 let lead_times = successful_deployments
1048 .iter()
1049 .filter_map(|deployment| {
1050 let commit_ids = deployment
1051 .attributes
1052 .get("commit_ids")
1053 .and_then(|value| value.as_array())?;
1054 commits
1055 .iter()
1056 .filter(|commit| {
1057 commit_ids.iter().any(|commit_id| {
1058 commit_id.as_str() == Some(commit.id.as_str())
1059 || commit_id.as_str() == Some(commit.source_event_id.as_str())
1060 })
1061 })
1062 .min_by_key(|commit| commit.occurred_at)
1063 .map(|commit| {
1064 (deployment.occurred_at - commit.occurred_at).num_seconds() as f64 / 3600.0
1065 })
1066 })
1067 .filter(|duration| *duration >= 0.0)
1068 .collect::<Vec<_>>();
1069 let correlated_failures = failed_deployments
1070 .iter()
1071 .filter(|failed| {
1072 failed
1073 .attributes
1074 .get("incident_id")
1075 .and_then(|value| value.as_str())
1076 .is_some_and(|incident_id| {
1077 events.iter().any(|event| {
1078 event.kind == DeliveryEventKind::IncidentOpened
1079 && (event.id == incident_id || event.source_event_id == incident_id)
1080 })
1081 })
1082 })
1083 .count();
1084 let recovery_times = failed_deployments
1085 .iter()
1086 .filter_map(|failed| {
1087 let incident_id = failed
1088 .attributes
1089 .get("incident_id")
1090 .and_then(|value| value.as_str())?;
1091 let opened = events.iter().find(|event| {
1092 event.kind == DeliveryEventKind::IncidentOpened
1093 && (event.id == incident_id || event.source_event_id == incident_id)
1094 })?;
1095 events
1096 .iter()
1097 .find(|event| {
1098 event.kind == DeliveryEventKind::IncidentResolved
1099 && (event.id == incident_id || event.source_event_id == incident_id)
1100 })
1101 .map(|commit| {
1102 (commit.occurred_at - opened.occurred_at).num_seconds() as f64 / 3600.0
1103 })
1104 })
1105 .collect::<Vec<_>>();
1106 let window_days = (window_end - window_start).num_seconds() as f64 / 86_400.0;
1107 Self {
1108 subject: None,
1109 deployment_frequency_per_week: (window_days > 0.0)
1110 .then(|| production_deployments.len() as f64 * 7.0 / window_days)
1111 .filter(|value| value.is_finite()),
1112 lead_time_for_changes_hours: mean(&lead_times).ok(),
1113 change_failure_rate: (deployment_count > 0)
1114 .then(|| failed_deployments.len() as f64 / deployment_count as f64),
1115 failed_deployment_recovery_hours: mean(&recovery_times).ok(),
1116 deployment_frequency_evidence: DoraMetricEvidence::with_availability(
1117 deployment_count,
1118 deployment_count,
1119 deployment_count,
1120 window_days > 0.0,
1121 ),
1122 lead_time_evidence: DoraMetricEvidence::new(
1123 successful_deployments.len(),
1124 correlated_deployments,
1125 lead_times.len(),
1126 ),
1127 change_failure_rate_evidence: DoraMetricEvidence::new(
1128 deployment_count,
1129 deployment_count,
1130 deployment_count,
1131 ),
1132 recovery_evidence: DoraMetricEvidence::new(
1133 failed_deployments.len(),
1134 correlated_failures,
1135 recovery_times.len(),
1136 ),
1137 deployment_frequency_status: DoraMetricEvidence::from_counts(
1138 deployment_count,
1139 deployment_count,
1140 deployment_count,
1141 )
1142 .status(),
1143 lead_time_status: DoraMetricEvidence::from_counts(
1144 successful_deployments.len(),
1145 correlated_deployments,
1146 lead_times.len(),
1147 )
1148 .status(),
1149 change_failure_rate_status: DoraMetricEvidence::from_counts(
1150 deployment_count,
1151 deployment_count,
1152 deployment_count,
1153 )
1154 .status(),
1155 recovery_status: DoraMetricEvidence::from_counts(
1156 failed_deployments.len(),
1157 correlated_failures,
1158 recovery_times.len(),
1159 )
1160 .status(),
1161 sample_count: lead_times.len(),
1162 window_start,
1163 window_end,
1164 }
1165 }
1166
1167 pub fn from_events_for_subject(
1168 events: &[DeliveryEvent],
1169 window_start: DateTime<Utc>,
1170 window_end: DateTime<Utc>,
1171 subject: &AnalyticsSubject,
1172 ) -> Self {
1173 let scoped = events
1174 .iter()
1175 .filter(|event| subject_matches(event, subject))
1176 .cloned()
1177 .collect::<Vec<_>>();
1178 let mut metrics = Self::from_events(&scoped, window_start, window_end);
1179 metrics.subject = Some(subject.clone());
1180 metrics
1181 }
1182}
1183
1184fn subject_matches(event: &DeliveryEvent, subject: &AnalyticsSubject) -> bool {
1185 match subject.kind {
1186 AnalyticsSubjectKind::Project => event
1187 .project_id
1188 .as_ref()
1189 .is_some_and(|value| value.0 == subject.id),
1190 AnalyticsSubjectKind::Repository => event
1191 .repository_id
1192 .as_ref()
1193 .is_some_and(|value| value.0 == subject.id),
1194 AnalyticsSubjectKind::Service
1195 | AnalyticsSubjectKind::Initiative
1196 | AnalyticsSubjectKind::Portfolio => {
1197 event
1198 .attributes
1199 .get(match subject.kind {
1200 AnalyticsSubjectKind::Service => "service_id",
1201 AnalyticsSubjectKind::Initiative => "initiative_id",
1202 AnalyticsSubjectKind::Portfolio => "portfolio_id",
1203 AnalyticsSubjectKind::Project | AnalyticsSubjectKind::Repository => {
1204 unreachable!()
1205 }
1206 })
1207 .and_then(|value| value.as_str())
1208 == Some(subject.id.as_str())
1209 }
1210 }
1211}
1212
1213fn has_failed_deployment_outcome(event: &DeliveryEvent) -> bool {
1214 event.kind == DeliveryEventKind::DeploymentFailed
1215 || event
1216 .attributes
1217 .get("outcome")
1218 .and_then(|value| value.as_str())
1219 .map(|outcome| {
1220 matches!(
1221 outcome.to_ascii_lowercase().as_str(),
1222 "failed" | "rolled_back" | "degraded"
1223 )
1224 })
1225 .unwrap_or(false)
1226}