1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4use std::collections::{BTreeMap, HashMap, HashSet};
5
6use serde::{Serialize, Serializer};
7
8mod confidence;
9mod evidence;
10mod options;
11mod route;
12mod scoring;
13mod temporal;
14
15pub use evidence::{EvidenceQuality, EvidenceQualityLevel, SignalCoverageStatus};
16pub use options::{
17 analyze_option_descriptors, AnalyzeConfigError, AnalyzeOptionDescriptor, AnalyzeOptions,
18 BlockingOptions, ConfidenceOptions, DownstreamOptions, EvidenceOptions, ExecutorOptions,
19 QueueingOptions, RouteOptions, TemporalOptions,
20};
21use tailtriage_core::{InFlightSnapshot, Run, RuntimeSnapshot};
22
23const ROUTE_DIVERGENCE_WARNING: &str =
24 "Different routes show different primary suspects; inspect route_breakdowns before acting on the global suspect.";
25const ROUTE_RUNTIME_ATTRIBUTION_WARNING: &str =
26 "Runtime and in-flight signals are global and are not attributed to this route.";
27
28const DUPLICATE_REQUEST_ID_WARNING_PREFIX: &str = "Duplicate completed request_id values detected";
29
30#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum ArtifactValidationError {
36 DuplicateCompletedRequestId {
38 request_ids: Vec<String>,
40 },
41 OrphanRequestScopedEvent {
43 section: &'static str,
45 request_ids: Vec<String>,
47 },
48}
49
50impl std::fmt::Display for ArtifactValidationError {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 match self {
53 Self::DuplicateCompletedRequestId { request_ids } => write!(
54 f,
55 "strict artifact validation failed: duplicate completed request_id value(s): {}. request_id must identify one completed logical request/work item within a Run",
56 request_ids.join(", ")
57 ),
58 Self::OrphanRequestScopedEvent {
59 section,
60 request_ids,
61 } => write!(
62 f,
63 "strict artifact validation failed: orphan {section} request_id value(s) with no matching completed request: {}",
64 request_ids.join(", ")
65 ),
66 }
67 }
68}
69
70impl std::error::Error for ArtifactValidationError {}
71
72pub fn validate_artifact_strict(run: &Run) -> Result<(), ArtifactValidationError> {
82 let duplicate_ids = duplicate_completed_request_ids(run);
83 if !duplicate_ids.is_empty() {
84 return Err(ArtifactValidationError::DuplicateCompletedRequestId {
85 request_ids: duplicate_ids,
86 });
87 }
88
89 let completed_ids = run
90 .requests
91 .iter()
92 .map(|request| request.request_id.as_str())
93 .collect::<HashSet<_>>();
94
95 let orphan_stage_ids = orphan_event_request_ids(&completed_ids, &run.stages, |stage| {
96 stage.request_id.as_str()
97 });
98 if !orphan_stage_ids.is_empty() {
99 return Err(ArtifactValidationError::OrphanRequestScopedEvent {
100 section: "stage",
101 request_ids: orphan_stage_ids,
102 });
103 }
104
105 let orphan_queue_ids = orphan_event_request_ids(&completed_ids, &run.queues, |queue| {
106 queue.request_id.as_str()
107 });
108 if !orphan_queue_ids.is_empty() {
109 return Err(ArtifactValidationError::OrphanRequestScopedEvent {
110 section: "queue",
111 request_ids: orphan_queue_ids,
112 });
113 }
114
115 Ok(())
116}
117
118pub fn try_analyze_run_strict_artifact(
124 run: &Run,
125 options: AnalyzeOptions,
126) -> Result<Report, AnalyzeRunError> {
127 options.validate()?;
128 validate_artifact_strict(run)?;
129 Ok(Analyzer::new(options).analyze_run(run))
130}
131
132#[derive(Debug)]
134pub enum AnalyzeRunError {
135 Config(AnalyzeConfigError),
137 Artifact(ArtifactValidationError),
139}
140
141impl std::fmt::Display for AnalyzeRunError {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 match self {
144 Self::Config(err) => err.fmt(f),
145 Self::Artifact(err) => err.fmt(f),
146 }
147 }
148}
149
150impl std::error::Error for AnalyzeRunError {
151 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
152 match self {
153 Self::Config(err) => Some(err),
154 Self::Artifact(err) => Some(err),
155 }
156 }
157}
158
159impl From<AnalyzeConfigError> for AnalyzeRunError {
160 fn from(value: AnalyzeConfigError) -> Self {
161 Self::Config(value)
162 }
163}
164
165impl From<ArtifactValidationError> for AnalyzeRunError {
166 fn from(value: ArtifactValidationError) -> Self {
167 Self::Artifact(value)
168 }
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum DiagnosisKind {
176 ApplicationQueueSaturation,
178 BlockingPoolPressure,
180 ExecutorPressureSuspected,
182 DownstreamStageDominates,
184 InsufficientEvidence,
186}
187
188impl DiagnosisKind {
189 #[must_use]
191 pub const fn as_str(&self) -> &'static str {
192 match self {
193 Self::ApplicationQueueSaturation => "application_queue_saturation",
194 Self::BlockingPoolPressure => "blocking_pool_pressure",
195 Self::ExecutorPressureSuspected => "executor_pressure_suspected",
196 Self::DownstreamStageDominates => "downstream_stage_dominates",
197 Self::InsufficientEvidence => "insufficient_evidence",
198 }
199 }
200}
201
202impl Serialize for DiagnosisKind {
203 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
204 where
205 S: Serializer,
206 {
207 serializer.serialize_str(self.as_str())
208 }
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
212#[serde(rename_all = "snake_case")]
213pub enum Confidence {
217 Low,
219 Medium,
221 High,
223}
224
225impl Confidence {
226 pub(crate) fn from_score_with_options(score: u8, options: &AnalyzeOptions) -> Self {
227 if score >= options.confidence.high_score_threshold {
228 Self::High
229 } else if score >= options.confidence.medium_score_threshold {
230 Self::Medium
231 } else {
232 Self::Low
233 }
234 }
235}
236
237#[derive(Debug, Clone, PartialEq, Serialize)]
241pub struct Suspect {
242 pub kind: DiagnosisKind,
244 pub score: u8,
246 pub confidence: Confidence,
248 pub evidence: Vec<String>,
250 pub next_checks: Vec<String>,
252 pub confidence_notes: Vec<String>,
254}
255
256impl Suspect {
257 fn new(
258 kind: DiagnosisKind,
259 score: u8,
260 evidence: Vec<String>,
261 next_checks: Vec<String>,
262 ) -> Self {
263 Self {
264 kind,
265 score,
266 confidence: Confidence::from_score_with_options(score, &AnalyzeOptions::default()),
267 evidence,
268 next_checks,
269 confidence_notes: Vec::new(),
270 }
271 }
272}
273
274#[derive(Debug, Clone, PartialEq, Serialize)]
276pub struct InflightTrend {
277 pub gauge: String,
279 pub sample_count: usize,
281 pub peak_count: u64,
283 pub p95_count: u64,
285 pub growth_delta: i64,
287 pub growth_per_sec_milli: Option<i64>,
289}
290
291#[derive(Debug, Clone, PartialEq, Serialize)]
296pub struct Report {
297 pub request_count: usize,
299 pub p50_latency_us: Option<u64>,
301 pub p95_latency_us: Option<u64>,
303 pub p99_latency_us: Option<u64>,
305 pub p95_queue_share_permille: Option<u64>,
307 pub p95_service_share_permille: Option<u64>,
309 pub inflight_trend: Option<InflightTrend>,
311 pub warnings: Vec<String>,
313 pub evidence_quality: EvidenceQuality,
315 pub primary_suspect: Suspect,
317 pub secondary_suspects: Vec<Suspect>,
319 pub route_breakdowns: Vec<RouteBreakdown>,
321 pub temporal_segments: Vec<TemporalSegment>,
323 #[serde(skip_serializing_if = "Option::is_none")]
325 pub analyzer_config: Option<AnalyzerConfigSummary>,
326}
327
328#[derive(Debug, Clone, PartialEq, Serialize)]
329pub struct AnalyzerConfigSummary {
331 pub schema_version: u32,
333 pub non_default_options: Vec<AnalyzeConfigOverrideSummary>,
335}
336
337#[derive(Debug, Clone, PartialEq, Serialize)]
338pub struct AnalyzeConfigOverrideSummary {
340 pub path: String,
342 pub value: String,
344}
345
346#[derive(Debug, Clone, PartialEq, Serialize)]
347pub struct TemporalSegment {
349 pub name: String,
351 pub request_count: usize,
353 pub started_at_unix_ms: Option<u64>,
355 pub finished_at_unix_ms: Option<u64>,
357 pub p50_latency_us: Option<u64>,
359 pub p95_latency_us: Option<u64>,
361 pub p99_latency_us: Option<u64>,
363 pub p95_queue_share_permille: Option<u64>,
365 pub p95_service_share_permille: Option<u64>,
367 pub evidence_quality: EvidenceQuality,
369 pub primary_suspect: Suspect,
371 pub secondary_suspects: Vec<Suspect>,
373 pub warnings: Vec<String>,
375}
376
377#[derive(Debug, Clone, PartialEq, Serialize)]
378pub struct RouteBreakdown {
380 pub route: String,
382 pub request_count: usize,
384 pub p50_latency_us: Option<u64>,
386 pub p95_latency_us: Option<u64>,
388 pub p99_latency_us: Option<u64>,
390 pub p95_queue_share_permille: Option<u64>,
392 pub p95_service_share_permille: Option<u64>,
394 pub evidence_quality: EvidenceQuality,
396 pub primary_suspect: Suspect,
398 pub secondary_suspects: Vec<Suspect>,
400 pub warnings: Vec<String>,
402}
403
404#[must_use]
466pub fn analyze_run(run: &Run, options: AnalyzeOptions) -> Report {
467 if let Err(err) = options.validate() {
468 panic!("invalid AnalyzeOptions passed to analyze_run: {err}");
469 }
470 Analyzer::new(options).analyze_run(run)
471}
472
473pub fn try_analyze_run(run: &Run, options: AnalyzeOptions) -> Result<Report, AnalyzeConfigError> {
479 options.validate()?;
480 Ok(Analyzer::new(options).analyze_run(run))
481}
482
483#[must_use = "The rendered JSON string should be used for output or transport."]
491pub fn render_json(report: &Report) -> Result<String, serde_json::Error> {
492 serde_json::to_string(report)
493}
494
495#[must_use = "The rendered JSON string should be used for output or transport."]
504pub fn render_json_pretty(report: &Report) -> Result<String, serde_json::Error> {
505 serde_json::to_string_pretty(report)
506}
507
508#[must_use = "The rendered JSON string should be used for output or transport."]
517pub fn analyze_run_json(
518 run: &tailtriage_core::Run,
519 options: AnalyzeOptions,
520) -> Result<String, serde_json::Error> {
521 let report = analyze_run(run, options);
522 render_json(&report)
523}
524
525pub fn try_analyze_run_json(
531 run: &tailtriage_core::Run,
532 options: AnalyzeOptions,
533) -> Result<String, AnalyzeConfigError> {
534 let report = try_analyze_run(run, options)?;
535 render_json(&report).map_err(|error| AnalyzeConfigError::InvalidConfigValue {
536 path: "analyzer.report_json",
537 message: format!("report serialization failed: {error}"),
538 })
539}
540
541#[must_use = "The rendered JSON string should be used for output or transport."]
550pub fn analyze_run_json_pretty(
551 run: &tailtriage_core::Run,
552 options: AnalyzeOptions,
553) -> Result<String, serde_json::Error> {
554 let report = analyze_run(run, options);
555 render_json_pretty(&report)
556}
557
558pub fn try_analyze_run_json_pretty(
564 run: &tailtriage_core::Run,
565 options: AnalyzeOptions,
566) -> Result<String, AnalyzeConfigError> {
567 let report = try_analyze_run(run, options)?;
568 render_json_pretty(&report).map_err(|error| AnalyzeConfigError::InvalidConfigValue {
569 path: "analyzer.report_json",
570 message: format!("report serialization failed: {error}"),
571 })
572}
573
574#[derive(Debug, Clone, Default)]
576pub struct Analyzer {
577 options: AnalyzeOptions,
578}
579
580impl Analyzer {
581 #[must_use]
583 pub const fn new(options: AnalyzeOptions) -> Self {
584 Self { options }
585 }
586
587 #[must_use]
589 pub fn analyze_run(&self, run: &Run) -> Report {
590 analyze_run_with_options(run, &self.options)
591 }
592}
593
594fn analyze_run_with_options(run: &Run, options: &AnalyzeOptions) -> Report {
595 let mut report = analyze_run_internal(run, options);
596 let route_context = route::route_breakdowns(run, &report, options);
597 if route_context.warn_on_divergence {
598 report.warnings.push(ROUTE_DIVERGENCE_WARNING.to_string());
599 }
600 report.route_breakdowns = route_context.breakdowns;
601 report.temporal_segments = temporal::temporal_segments(run, &mut report.warnings, options);
602 let overrides = options.non_default_overrides();
603 report.analyzer_config = if overrides.is_empty() {
604 None
605 } else {
606 Some(AnalyzerConfigSummary {
607 schema_version: 1,
608 non_default_options: overrides,
609 })
610 };
611 report
612}
613
614fn analyze_run_internal(run: &Run, options: &AnalyzeOptions) -> Report {
615 let request_latencies = run
616 .requests
617 .iter()
618 .map(|request| request.latency_us)
619 .collect::<Vec<_>>();
620
621 let p50_latency_us = percentile(&request_latencies, 50, 100);
622 let p95_latency_us = percentile(&request_latencies, 95, 100);
623 let p99_latency_us = percentile(&request_latencies, 99, 100);
624 let (queue_shares, service_shares) = request_time_shares(run);
625 let p95_queue_share_permille = percentile(&queue_shares, 95, 100);
626 let p95_service_share_permille = percentile(&service_shares, 95, 100);
627 let inflight_trend = dominant_inflight_trend(&run.inflight);
628
629 let mut suspects = Vec::new();
630
631 if let Some(queue_suspect) =
632 scoring::queue_saturation_suspect(run, inflight_trend.as_ref(), options)
633 {
634 suspects.push(queue_suspect);
635 }
636
637 if let Some(blocking_suspect) = scoring::blocking_pressure_suspect(run, options) {
638 suspects.push(blocking_suspect);
639 }
640
641 if let Some(executor_suspect) =
642 scoring::executor_pressure_suspect(run, inflight_trend.as_ref(), options)
643 {
644 suspects.push(executor_suspect);
645 }
646
647 if let Some(stage_suspect) = scoring::downstream_stage_suspect(run, options) {
648 suspects.push(stage_suspect);
649 }
650
651 if suspects.is_empty() {
652 suspects.push(Suspect::new(
653 DiagnosisKind::InsufficientEvidence,
654 50,
655 vec![
656 "Not enough queue, stage, or runtime signals to rank a stronger suspect."
657 .to_string(),
658 ],
659 vec![
660 "Wrap critical awaits with queue(...).await_on(...), and use stage(...).await_on(...) for Result-returning work or stage(...).await_value(...) for infallible work.".to_string(),
661 "Enable RuntimeSampler during the run to capture runtime pressure signals."
662 .to_string(),
663 ],
664 ));
665 }
666
667 suspects.sort_by_key(|suspect| std::cmp::Reverse(suspect.score));
668
669 let warnings = analysis_warnings(run, &suspects, options);
670 let evidence_quality = evidence::evidence_quality(run, options);
671
672 for suspect in &mut suspects {
673 suspect.confidence = Confidence::from_score_with_options(suspect.score, options);
674 }
675 confidence::apply_evidence_aware_confidence_caps(
676 &mut suspects,
677 run,
678 &evidence_quality,
679 options,
680 );
681
682 let mut ranked = suspects.into_iter();
683 let primary_suspect = ranked.next().unwrap_or_else(|| {
684 Suspect::new(
685 DiagnosisKind::InsufficientEvidence,
686 50,
687 vec!["No diagnosis signals were captured for this run.".to_string()],
688 vec!["Verify that request, queue, or stage instrumentation is enabled.".to_string()],
689 )
690 });
691
692 Report {
693 request_count: run.requests.len(),
694 p50_latency_us,
695 p95_latency_us,
696 p99_latency_us,
697 p95_queue_share_permille,
698 p95_service_share_permille,
699 inflight_trend,
700 warnings,
701 evidence_quality,
702 primary_suspect,
703 secondary_suspects: ranked.collect(),
704 route_breakdowns: Vec::new(),
705 temporal_segments: Vec::new(),
706 analyzer_config: None,
707 }
708}
709
710fn ambiguity_warning(suspects: &[Suspect], options: &AnalyzeOptions) -> Option<String> {
711 let mut ranked = suspects
712 .iter()
713 .filter(|s| s.kind != DiagnosisKind::InsufficientEvidence)
714 .collect::<Vec<_>>();
715 ranked.sort_by_key(|s| std::cmp::Reverse(s.score));
716 if ranked.len() >= 2
717 && ranked[0].score >= options.confidence.ambiguity_min_score
718 && ranked[1].score >= options.confidence.ambiguity_min_score
719 && ranked[0].score.abs_diff(ranked[1].score) <= options.confidence.ambiguity_score_gap
720 {
721 Some("Top suspects are close in score; treat ranking as ambiguous and validate both with next checks.".to_string())
722 } else {
723 None
724 }
725}
726
727fn analysis_warnings(run: &Run, suspects: &[Suspect], options: &AnalyzeOptions) -> Vec<String> {
728 let mut warnings = evidence::truncation_warnings(run);
729 if let Some(warning) = duplicate_completed_request_id_warning(run) {
730 warnings.push(warning);
731 }
732 warnings.extend(orphan_request_scoped_event_warnings(run));
733 if run.requests.len() < options.evidence.low_completed_request_threshold {
734 warnings.push(
735 "Low completed-request count; diagnosis ranking may be unstable for this run window."
736 .to_string(),
737 );
738 }
739 let primary_kind = suspects.first().map(|s| &s.kind);
740 if run.queues.is_empty()
741 && primary_kind.is_some_and(|kind| *kind == DiagnosisKind::ApplicationQueueSaturation)
742 {
743 warnings.push(
744 "No queue events captured; queue saturation interpretation is limited.".to_string(),
745 );
746 }
747 if run.stages.is_empty()
748 && primary_kind.is_some_and(|kind| *kind == DiagnosisKind::DownstreamStageDominates)
749 {
750 warnings.push(
751 "No stage events captured; downstream-stage interpretation is limited.".to_string(),
752 );
753 }
754 let runtime_distinction_relevant = suspects.iter().any(|s| {
755 s.kind == DiagnosisKind::BlockingPoolPressure
756 || s.kind == DiagnosisKind::ExecutorPressureSuspected
757 });
758 let strong_non_runtime_primary = suspects.first().is_some_and(|s| {
759 (s.kind == DiagnosisKind::ApplicationQueueSaturation
760 || s.kind == DiagnosisKind::DownstreamStageDominates)
761 && s.score >= options.confidence.high_score_threshold
762 });
763
764 if run.runtime_snapshots.is_empty() {
765 if !strong_non_runtime_primary {
766 warnings.push("No runtime snapshots captured; executor and blocking-pressure interpretation is limited.".to_string());
767 }
768 } else if runtime_distinction_relevant
769 && (run
770 .runtime_snapshots
771 .iter()
772 .all(|s| s.blocking_queue_depth.is_none())
773 || run
774 .runtime_snapshots
775 .iter()
776 .all(|s| s.local_queue_depth.is_none()))
777 {
778 warnings.push("Runtime snapshots are missing blocking_queue_depth or local_queue_depth; separating executor vs blocking pressure is limited.".to_string());
779 }
780 if let Some(w) = ambiguity_warning(suspects, options) {
781 warnings.push(w);
782 }
783 warnings
784}
785
786fn duplicate_completed_request_id_warning(run: &Run) -> Option<String> {
787 let duplicates = duplicate_completed_request_ids(run);
788 if duplicates.is_empty() {
789 return None;
790 }
791 Some(format!(
792 "{DUPLICATE_REQUEST_ID_WARNING_PREFIX}: {}. request_id must be unique per completed logical request/work item in one Run; request-scoped queue attribution, route breakdowns, temporal segmentation, and downstream-stage matching may be ambiguous. Default analysis is permissive; use strict artifact validation to fail this artifact.",
793 duplicates.join(", ")
794 ))
795}
796
797fn duplicate_completed_request_ids(run: &Run) -> Vec<String> {
798 let mut seen = HashSet::<&str>::new();
799 let mut duplicates = HashSet::<&str>::new();
800 for request in &run.requests {
801 if !seen.insert(request.request_id.as_str()) {
802 duplicates.insert(request.request_id.as_str());
803 }
804 }
805 let mut duplicates = duplicates
806 .into_iter()
807 .map(str::to_owned)
808 .collect::<Vec<_>>();
809 duplicates.sort();
810 duplicates
811}
812
813fn orphan_event_request_ids<T>(
814 completed_ids: &HashSet<&str>,
815 events: &[T],
816 request_id: impl Fn(&T) -> &str,
817) -> Vec<String> {
818 let mut orphan_ids = HashSet::<&str>::new();
819 for event in events {
820 let id = request_id(event);
821 if !completed_ids.contains(id) {
822 orphan_ids.insert(id);
823 }
824 }
825 let mut orphan_ids = orphan_ids
826 .into_iter()
827 .map(str::to_owned)
828 .collect::<Vec<_>>();
829 orphan_ids.sort();
830 orphan_ids
831}
832
833fn orphan_request_scoped_event_warnings(run: &Run) -> Vec<String> {
834 let completed_ids = run
835 .requests
836 .iter()
837 .map(|request| request.request_id.as_str())
838 .collect::<HashSet<_>>();
839 let mut warnings = Vec::new();
840
841 let orphan_stage_ids = orphan_event_request_ids(&completed_ids, &run.stages, |stage| {
842 stage.request_id.as_str()
843 });
844 if !orphan_stage_ids.is_empty() {
845 warnings.push(format!(
846 "Orphan stage request_id value(s) without matching completed requests: {}. Downstream-stage matching may be incomplete; use strict artifact validation to fail this artifact.",
847 orphan_stage_ids.join(", ")
848 ));
849 }
850
851 let orphan_queue_ids = orphan_event_request_ids(&completed_ids, &run.queues, |queue| {
852 queue.request_id.as_str()
853 });
854 if !orphan_queue_ids.is_empty() {
855 warnings.push(format!(
856 "Orphan queue request_id value(s) without matching completed requests: {}. Queue attribution may be incomplete; use strict artifact validation to fail this artifact.",
857 orphan_queue_ids.join(", ")
858 ));
859 }
860
861 warnings
862}
863
864fn request_time_shares(run: &Run) -> (Vec<u64>, Vec<u64>) {
865 let mut total_queue_wait_by_request = HashMap::<&str, u64>::new();
866 for queue in &run.queues {
867 *total_queue_wait_by_request
868 .entry(queue.request_id.as_str())
869 .or_default() = total_queue_wait_by_request
870 .get(queue.request_id.as_str())
871 .copied()
872 .unwrap_or_default()
873 .saturating_add(queue.wait_us);
874 }
875
876 let mut queue_shares = Vec::new();
877 let mut service_shares = Vec::new();
878
879 for request in &run.requests {
880 if request.latency_us == 0 {
881 continue;
882 }
883
884 let queue_wait = total_queue_wait_by_request
885 .get(request.request_id.as_str())
886 .copied()
887 .unwrap_or_default()
888 .min(request.latency_us);
889 let service_time = request.latency_us.saturating_sub(queue_wait);
890
891 queue_shares.push(queue_wait.saturating_mul(1_000) / request.latency_us);
892 service_shares.push(service_time.saturating_mul(1_000) / request.latency_us);
893 }
894
895 (queue_shares, service_shares)
896}
897
898fn runtime_metric_series(
899 snapshots: &[RuntimeSnapshot],
900 selector: impl Fn(&RuntimeSnapshot) -> Option<u64>,
901) -> Vec<u64> {
902 snapshots.iter().filter_map(selector).collect::<Vec<_>>()
903}
904
905fn dominant_inflight_trend(snapshots: &[InFlightSnapshot]) -> Option<InflightTrend> {
906 let mut by_gauge: BTreeMap<&str, Vec<&InFlightSnapshot>> = BTreeMap::new();
907 for snapshot in snapshots {
908 by_gauge
909 .entry(snapshot.gauge.as_str())
910 .or_default()
911 .push(snapshot);
912 }
913
914 by_gauge
915 .into_iter()
916 .filter_map(|(gauge, samples)| inflight_trend_for_gauge(gauge, samples))
917 .max_by(|left, right| {
918 left.peak_count
919 .cmp(&right.peak_count)
920 .then_with(|| left.p95_count.cmp(&right.p95_count))
921 .then_with(|| left.gauge.cmp(&right.gauge).reverse())
922 })
923}
924
925fn inflight_trend_for_gauge(
926 gauge: &str,
927 mut samples: Vec<&InFlightSnapshot>,
928) -> Option<InflightTrend> {
929 if samples.is_empty() {
930 return None;
931 }
932
933 samples.sort_unstable_by(|left, right| {
934 left.at_unix_ms
935 .cmp(&right.at_unix_ms)
936 .then_with(|| left.count.cmp(&right.count))
937 });
938
939 let counts = samples
940 .iter()
941 .map(|sample| sample.count)
942 .collect::<Vec<_>>();
943 let first = samples.first()?;
944 let last = samples.last()?;
945 let growth_delta = signed_u64_delta(first.count, last.count);
946 let window_ms = last.at_unix_ms.saturating_sub(first.at_unix_ms);
947 let growth_per_sec_milli = if window_ms == 0 {
948 None
949 } else {
950 i64::try_from(window_ms)
951 .ok()
952 .map(|window_ms_i64| growth_delta.saturating_mul(1_000_000) / window_ms_i64)
953 };
954
955 Some(InflightTrend {
956 gauge: gauge.to_owned(),
957 sample_count: counts.len(),
958 peak_count: counts.iter().copied().max().unwrap_or(0),
959 p95_count: percentile(&counts, 95, 100).unwrap_or(0),
960 growth_delta,
961 growth_per_sec_milli,
962 })
963}
964
965fn signed_u64_delta(start: u64, end: u64) -> i64 {
966 if end >= start {
967 i64::try_from(end - start).unwrap_or(i64::MAX)
968 } else {
969 -i64::try_from(start - end).unwrap_or(i64::MAX)
970 }
971}
972
973fn percentile(values: &[u64], numerator: usize, denominator: usize) -> Option<u64> {
974 let sorted = sorted_u64(values);
975 percentile_sorted_u64(&sorted, numerator, denominator)
976}
977
978fn sorted_u64(values: &[u64]) -> Vec<u64> {
979 let mut sorted = values.to_vec();
980 sorted.sort_unstable();
981 sorted
982}
983
984fn percentile_sorted_u64(values: &[u64], numerator: usize, denominator: usize) -> Option<u64> {
985 if values.is_empty() {
986 return None;
987 }
988 if denominator == 0 {
989 return None;
990 }
991
992 let max_index = values.len().saturating_sub(1);
993 let index = max_index
994 .saturating_mul(numerator)
995 .div_ceil(denominator)
996 .min(max_index);
997 values.get(index).copied()
998}
999
1000pub use render::render_text;
1001
1002mod render;
1003
1004#[cfg(test)]
1005mod tests;