Skip to main content

tailtriage_analyzer/
lib.rs

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/// Errors returned by strict run-artifact validation.
31///
32/// Default analysis remains permissive and reports these as warnings where possible;
33/// strict validation is for callers that want to reject ambiguous artifacts before triage.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum ArtifactValidationError {
36    /// More than one completed request event used the same `request_id`.
37    DuplicateCompletedRequestId {
38        /// Duplicate request IDs found in completed requests.
39        request_ids: Vec<String>,
40    },
41    /// Stage or queue evidence referenced a `request_id` with no completed request.
42    OrphanRequestScopedEvent {
43        /// Section containing orphan request-scoped events.
44        section: &'static str,
45        /// Orphan request IDs found in that section.
46        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
72/// Strictly validates request-scoped artifact invariants before analysis.
73///
74/// This rejects duplicate completed request IDs and stage/queue events whose
75/// `request_id` has no matching completed request. Default analyzer entry
76/// points do not call this automatically; they keep backward-compatible
77/// permissive behavior and emit warnings instead of failing.
78///
79/// # Errors
80/// Returns [`ArtifactValidationError`] for the first strict artifact invariant failure.
81pub 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
118/// Validates options and strict artifact invariants, then analyzes one [`Run`].
119///
120/// # Errors
121/// Returns an error when analyzer options are invalid or strict artifact
122/// validation fails.
123pub 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/// Error returned by [`try_analyze_run_strict_artifact`].
133#[derive(Debug)]
134pub enum AnalyzeRunError {
135    /// Analyzer option validation failed.
136    Config(AnalyzeConfigError),
137    /// Strict artifact validation failed.
138    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/// Evidence-ranked diagnosis categories produced by heuristic triage.
172///
173/// These categories are leads for investigation and are not proof of root cause.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum DiagnosisKind {
176    /// Queue wait dominates request latency, suggesting application-level queue pressure.
177    ApplicationQueueSaturation,
178    /// Blocking pool backlog suggests pressure in `spawn_blocking`-backed work.
179    BlockingPoolPressure,
180    /// Runtime scheduler queueing suggests potential executor pressure.
181    ExecutorPressureSuspected,
182    /// One stage dominates aggregate latency, suggesting downstream slowdown.
183    DownstreamStageDominates,
184    /// Captured signals are too sparse to rank stronger suspects.
185    InsufficientEvidence,
186}
187
188impl DiagnosisKind {
189    /// Returns the stable machine-readable diagnosis kind label.
190    #[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")]
213/// Confidence bucket derived from suspect score thresholds.
214///
215/// This is score-derived ranking confidence, not causal certainty.
216pub enum Confidence {
217    /// Weak signal quality relative to stronger suspects in the same report.
218    Low,
219    /// Moderate signal quality for triage follow-up.
220    Medium,
221    /// Strong signal quality for triage follow-up.
222    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/// Evidence-ranked suspect produced by heuristic analysis.
238///
239/// Suspects are triage leads and should be validated with follow-up checks.
240#[derive(Debug, Clone, PartialEq, Serialize)]
241pub struct Suspect {
242    /// Ranked suspect category.
243    pub kind: DiagnosisKind,
244    /// Relative ranking score in range `0..=100` (higher means stronger evidence).
245    pub score: u8,
246    /// Score-derived confidence bucket for triage prioritization.
247    pub confidence: Confidence,
248    /// Supporting evidence strings used to justify this suspect ranking.
249    pub evidence: Vec<String>,
250    /// Recommended next checks to validate or falsify this suspect.
251    pub next_checks: Vec<String>,
252    /// Machine-readable notes explaining confidence caps due to evidence limitations.
253    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/// Summary of one dominant in-flight gauge trend over the run window.
275#[derive(Debug, Clone, PartialEq, Serialize)]
276pub struct InflightTrend {
277    /// Gauge name chosen as the dominant trend candidate.
278    pub gauge: String,
279    /// Number of snapshots seen for this gauge.
280    pub sample_count: usize,
281    /// Peak in-flight count observed for this gauge.
282    pub peak_count: u64,
283    /// p95 in-flight count for this gauge.
284    pub p95_count: u64,
285    /// Net growth (`last - first`) across the sampled run window.
286    pub growth_delta: i64,
287    /// Growth rate in milli-counts/sec, if timestamps permit calculation.
288    pub growth_per_sec_milli: Option<i64>,
289}
290
291/// Rule-based triage report for one completed [`Run`] snapshot.
292///
293/// The report ranks evidence-backed suspects and suggests next checks.
294/// It does not prove root cause and should be used as triage guidance.
295#[derive(Debug, Clone, PartialEq, Serialize)]
296pub struct Report {
297    /// Number of request events considered in analysis.
298    pub request_count: usize,
299    /// p50 request latency in microseconds.
300    pub p50_latency_us: Option<u64>,
301    /// p95 request latency in microseconds.
302    pub p95_latency_us: Option<u64>,
303    /// p99 request latency in microseconds.
304    pub p99_latency_us: Option<u64>,
305    /// p95 queue-time share per request in permille (`0..=1000`).
306    pub p95_queue_share_permille: Option<u64>,
307    /// p95 non-queue service-time share per request in permille (`0..=1000`).
308    pub p95_service_share_permille: Option<u64>,
309    /// Dominant in-flight trend signal, when at least one in-flight gauge has samples.
310    pub inflight_trend: Option<InflightTrend>,
311    /// Non-fatal analysis warnings (for example, capture truncation notices).
312    pub warnings: Vec<String>,
313    /// Structured evidence coverage and interpretation quality summary.
314    pub evidence_quality: EvidenceQuality,
315    /// Highest-ranked suspect from this run.
316    pub primary_suspect: Suspect,
317    /// Lower-ranked suspects retained for follow-up triage.
318    pub secondary_suspects: Vec<Suspect>,
319    /// Supporting per-route triage summaries when route-level signal adds value.
320    pub route_breakdowns: Vec<RouteBreakdown>,
321    /// Supporting early/late temporal triage summaries when within-run shifts add value.
322    pub temporal_segments: Vec<TemporalSegment>,
323    /// Non-default analyzer configuration overrides used for this report, when present.
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub analyzer_config: Option<AnalyzerConfigSummary>,
326}
327
328#[derive(Debug, Clone, PartialEq, Serialize)]
329/// Summary of non-default analyzer options used during analysis.
330pub struct AnalyzerConfigSummary {
331    /// Analyzer config summary schema version.
332    pub schema_version: u32,
333    /// Non-default semantic analyzer options rendered as stable path/value pairs.
334    pub non_default_options: Vec<AnalyzeConfigOverrideSummary>,
335}
336
337#[derive(Debug, Clone, PartialEq, Serialize)]
338/// One non-default analyzer option override rendered as a stable path/value pair.
339pub struct AnalyzeConfigOverrideSummary {
340    /// Stable semantic option path.
341    pub path: String,
342    /// Stable string-rendered option value.
343    pub value: String,
344}
345
346#[derive(Debug, Clone, PartialEq, Serialize)]
347/// Supporting early/late temporal triage summary for one run.
348pub struct TemporalSegment {
349    /// Segment label, currently `early` or `late`.
350    pub name: String,
351    /// Completed request count included in this segment.
352    pub request_count: usize,
353    /// Earliest request start timestamp in the segment.
354    pub started_at_unix_ms: Option<u64>,
355    /// Latest request finish timestamp in the segment.
356    pub finished_at_unix_ms: Option<u64>,
357    /// p50 request latency for this segment in microseconds.
358    pub p50_latency_us: Option<u64>,
359    /// p95 request latency for this segment in microseconds.
360    pub p95_latency_us: Option<u64>,
361    /// p99 request latency for this segment in microseconds.
362    pub p99_latency_us: Option<u64>,
363    /// p95 queue-time share for this segment in permille.
364    pub p95_queue_share_permille: Option<u64>,
365    /// p95 non-queue service-time share for this segment in permille.
366    pub p95_service_share_permille: Option<u64>,
367    /// Evidence coverage summary for this segment.
368    pub evidence_quality: EvidenceQuality,
369    /// Highest-ranked segment-level suspect.
370    pub primary_suspect: Suspect,
371    /// Lower-ranked segment-level suspects for follow-up.
372    pub secondary_suspects: Vec<Suspect>,
373    /// Segment-scoped warnings and interpretation limits.
374    pub warnings: Vec<String>,
375}
376
377#[derive(Debug, Clone, PartialEq, Serialize)]
378/// Supporting per-route triage summary derived from captured request route labels.
379pub struct RouteBreakdown {
380    /// Route or operation label from request capture.
381    pub route: String,
382    /// Completed request count included for this route.
383    pub request_count: usize,
384    /// p50 request latency for this route in microseconds.
385    pub p50_latency_us: Option<u64>,
386    /// p95 request latency for this route in microseconds.
387    pub p95_latency_us: Option<u64>,
388    /// p99 request latency for this route in microseconds.
389    pub p99_latency_us: Option<u64>,
390    /// p95 queue-time share for this route in permille.
391    pub p95_queue_share_permille: Option<u64>,
392    /// p95 non-queue service-time share for this route in permille.
393    pub p95_service_share_permille: Option<u64>,
394    /// Evidence coverage summary for this route-filtered analysis.
395    pub evidence_quality: EvidenceQuality,
396    /// Highest-ranked route-level suspect.
397    pub primary_suspect: Suspect,
398    /// Lower-ranked route-level suspects for follow-up.
399    pub secondary_suspects: Vec<Suspect>,
400    /// Route-scoped warnings and interpretation limits.
401    pub warnings: Vec<String>,
402}
403
404/// Analyzes one completed [`Run`] with rule-based heuristics and returns a triage report.
405///
406/// The analysis ranks evidence-backed suspects and next checks; it does not
407/// claim causal certainty or proven root cause.
408///
409/// `request_id` is the per-run identity of one completed logical request/work item.
410/// It must be unique among completed requests in a `Run`, and stage/queue events
411/// must reuse that ID only for the same logical request. Default analysis warns
412/// about duplicate completed IDs; use [`validate_artifact_strict`] or
413/// [`try_analyze_run_strict_artifact`] to reject duplicate or orphan request-scoped
414/// evidence before analysis. Users remain responsible for meaningful
415/// instrumentation and request-boundary semantics.
416///
417/// # Examples
418///
419/// Library API example (this does not use the CLI file-loader contract):
420///
421/// ```
422/// use tailtriage_analyzer::{analyze_run, AnalyzeOptions};
423/// use tailtriage_core::{
424///     CaptureMode, EffectiveCoreConfig, Run, RunMetadata, UnfinishedRequests, SCHEMA_VERSION,
425/// };
426///
427/// let run = Run {
428///     schema_version: SCHEMA_VERSION,
429///     metadata: RunMetadata {
430///         run_id: "run-1".to_string(),
431///         service_name: "svc".to_string(),
432///         service_version: None,
433///         started_at_unix_ms: 1,
434///         finished_at_unix_ms: 2,
435///         finalized_at_unix_ms: Some(2),
436///         mode: CaptureMode::Light,
437///         effective_core_config: Some(EffectiveCoreConfig {
438///             mode: CaptureMode::Light,
439///             capture_limits: CaptureMode::Light.core_defaults(),
440///             strict_lifecycle: false,
441///         }),
442///         effective_tokio_sampler_config: None,
443///         host: None,
444///         pid: None,
445///         lifecycle_warnings: Vec::new(),
446///         unfinished_requests: UnfinishedRequests::default(),
447///         run_end_reason: None,
448///     },
449///     requests: vec![],
450///     stages: vec![],
451///     queues: vec![],
452///     inflight: vec![],
453///     runtime_snapshots: vec![],
454///     truncation: Default::default(),
455/// };
456///
457/// // `analyze_run(&Run, AnalyzeOptions)` can operate on an in-memory run with zero requests.
458/// let report = analyze_run(&run, AnalyzeOptions::default());
459/// assert_eq!(report.request_count, 0);
460/// ```
461///
462/// # Panics
463///
464/// Panics if `options` fails semantic validation. Use [`try_analyze_run`] to handle invalid options as errors.
465#[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
473/// Analyzes one completed [`Run`] with validated options and returns a triage report.
474///
475/// # Errors
476///
477/// Returns an error when options fail semantic validation.
478pub 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/// Renders analyzer [`Report`] JSON in compact form.
484///
485/// This renders analyzer report JSON (the diagnosis output), not raw run artifact JSON.
486///
487/// # Errors
488///
489/// Returns any serialization error from `serde_json::to_string`.
490#[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/// Renders analyzer [`Report`] JSON in canonical pretty form.
496///
497/// This renders analyzer report JSON (the diagnosis output), not raw run artifact JSON.
498/// The pretty output is intended as the canonical renderer for CLI JSON output.
499///
500/// # Errors
501///
502/// Returns any serialization error from `serde_json::to_string_pretty`.
503#[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/// Analyzes one in-memory [`Run`] and returns compact analyzer [`Report`] JSON.
509///
510/// This analyzes a run artifact already loaded in memory and returns analyzer report JSON,
511/// not raw run artifact JSON.
512///
513/// # Errors
514///
515/// Returns any serialization error from [`render_json`].
516#[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
525/// Analyzes one in-memory [`Run`] and returns compact analyzer [`Report`] JSON.
526///
527/// # Errors
528///
529/// Returns an error when options fail validation or JSON serialization fails.
530pub 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/// Analyzes one in-memory [`Run`] and returns canonical pretty analyzer [`Report`] JSON.
542///
543/// This analyzes a run artifact already loaded in memory and returns analyzer report JSON,
544/// not raw run artifact JSON. The pretty output is intended for CLI JSON output.
545///
546/// # Errors
547///
548/// Returns any serialization error from [`render_json_pretty`].
549#[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
558/// Analyzes one in-memory [`Run`] and returns pretty analyzer [`Report`] JSON.
559///
560/// # Errors
561///
562/// Returns an error when options fail validation or JSON serialization fails.
563pub 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/// Reusable analyzer configured with [`AnalyzeOptions`].
575#[derive(Debug, Clone, Default)]
576pub struct Analyzer {
577    options: AnalyzeOptions,
578}
579
580impl Analyzer {
581    /// Creates an analyzer with the provided options.
582    #[must_use]
583    pub const fn new(options: AnalyzeOptions) -> Self {
584        Self { options }
585    }
586
587    /// Analyzes one completed [`Run`] (or stable snapshot equivalent) and returns a triage report.
588    #[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;