1use crate::report::{
5 ActionStatus, ProductStageRecord, ProductStatus, ReportMode, ReportRecord, Status,
6 TelemetryScope, TelemetryValue,
7};
8use crate::cmake::{
9 CmakeCheckFailClass, CmakeCheckRecord, CmakeCheckStatus, CmakeMissingArtifact,
10 CmakeModelReportHeader, CmakeModelReportRecord, CmakeSummaryRecord, CmakeTargetRef,
11};
12use serde::{Deserialize, Serialize};
13use serde_json::{Number, Value};
14use std::collections::{BTreeMap, BTreeSet};
15use std::fmt;
16
17pub type AnalyticsResult<T> = Result<T, AnalyticsError>;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum AnalyticsErrorCode {
21 InvalidJsonl,
22 InvalidReportShape,
23 InvalidCaseIdentity,
24 InvalidTelemetryShape,
25 InvalidBuildMeta,
26 InvalidCubeShape,
27 InvalidCompareShape,
28 InvalidCompareIndexShape,
29 DuplicateBuildId,
30 MissingBuildId,
31 InvalidCompareMeta,
32 UnknownRelationBuild,
33 UnsupportedRelationKind,
34 InvalidCompareCardinality,
35 EmptyCubeDir,
36}
37
38impl AnalyticsErrorCode {
39 pub fn as_str(self) -> &'static str {
40 match self {
41 AnalyticsErrorCode::InvalidJsonl => "invalid_jsonl",
42 AnalyticsErrorCode::InvalidReportShape => "invalid_report_shape",
43 AnalyticsErrorCode::InvalidCaseIdentity => "invalid_case_identity",
44 AnalyticsErrorCode::InvalidTelemetryShape => "invalid_telemetry_shape",
45 AnalyticsErrorCode::InvalidBuildMeta => "invalid_build_meta",
46 AnalyticsErrorCode::InvalidCubeShape => "invalid_cube_shape",
47 AnalyticsErrorCode::InvalidCompareShape => "invalid_compare_shape",
48 AnalyticsErrorCode::InvalidCompareIndexShape => "invalid_compare_index_shape",
49 AnalyticsErrorCode::DuplicateBuildId => "duplicate_build_id",
50 AnalyticsErrorCode::MissingBuildId => "missing_build_id",
51 AnalyticsErrorCode::InvalidCompareMeta => "invalid_compare_meta",
52 AnalyticsErrorCode::UnknownRelationBuild => "unknown_relation_build",
53 AnalyticsErrorCode::UnsupportedRelationKind => "unsupported_relation_kind",
54 AnalyticsErrorCode::InvalidCompareCardinality => "invalid_compare_cardinality",
55 AnalyticsErrorCode::EmptyCubeDir => "empty_cube_dir",
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct AnalyticsError {
62 pub code: AnalyticsErrorCode,
63 pub msg: String,
64}
65
66impl AnalyticsError {
67 pub fn new(code: AnalyticsErrorCode, msg: impl Into<String>) -> Self {
68 Self {
69 code,
70 msg: msg.into(),
71 }
72 }
73}
74
75impl fmt::Display for AnalyticsError {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 write!(f, "{}", self.msg)
78 }
79}
80
81impl std::error::Error for AnalyticsError {}
82
83#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct BuildMeta {
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub branch: Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub commit: Option<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub job_id: Option<String>,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub pipeline_id: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub runner: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub parent_build_id: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub shard_id: Option<String>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub subbuild_id: Option<String>,
102 #[serde(skip_serializing)]
103 pub build_id: Option<String>,
104}
105
106impl BuildMeta {
107 pub fn parse_json(source: &str) -> AnalyticsResult<Self> {
108 let value: Value = serde_json::from_str(source).map_err(|error| {
109 AnalyticsError::new(
110 AnalyticsErrorCode::InvalidBuildMeta,
111 format!("invalid build metadata json: {error}"),
112 )
113 })?;
114 if let Value::Object(map) = &value {
115 for forbidden in ["inventory_sha256", "suite_sha256", "report_mode"] {
116 if map.contains_key(forbidden) {
117 return Err(AnalyticsError::new(
118 AnalyticsErrorCode::InvalidBuildMeta,
119 format!("build metadata must not supply canonical field `{forbidden}`"),
120 ));
121 }
122 }
123 for (key, item) in map {
124 if !item.is_string() && !item.is_null() {
125 return Err(AnalyticsError::new(
126 AnalyticsErrorCode::InvalidBuildMeta,
127 format!("build metadata field `{key}` must be a string"),
128 ));
129 }
130 }
131 }
132 serde_json::from_value(value).map_err(|error| {
133 AnalyticsError::new(
134 AnalyticsErrorCode::InvalidBuildMeta,
135 format!("invalid build metadata shape: {error}"),
136 )
137 })
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Serialize)]
142pub struct BuildCube {
143 pub k: &'static str,
144 pub v: &'static str,
145 pub build: CubeBuild,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub stage: Option<CubeStage>,
148 pub order: CubeOrder,
149 pub dimensions: CubeDimensions,
150 pub facts: CubeFacts,
151 pub measures: CubeMeasures,
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize)]
155pub struct CubeBuild {
156 #[serde(skip_serializing_if = "Option::is_none")]
157 pub build_id: Option<String>,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub inventory_sha256: Option<String>,
160 #[serde(skip_serializing_if = "Option::is_none")]
161 pub suite_sha256: Option<String>,
162 #[serde(skip_serializing_if = "Option::is_none")]
163 pub model_sha256: Option<String>,
164 #[serde(skip_serializing_if = "Option::is_none")]
165 pub report_sha256: Option<String>,
166 pub report_mode: ReportMode,
167 pub meta: BuildMeta,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct CubeStage {
172 pub runner_kind: String,
173 pub status: ProductStatus,
174 pub exit_code: i32,
175 pub required: bool,
176 #[serde(skip_serializing_if = "Option::is_none")]
177 pub case_pass: Option<usize>,
178 #[serde(skip_serializing_if = "Option::is_none")]
179 pub case_fail: Option<usize>,
180 #[serde(skip_serializing_if = "Option::is_none")]
181 pub assert_pass: Option<usize>,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 pub assert_fail: Option<usize>,
184 #[serde(skip_serializing_if = "Option::is_none")]
185 pub check_pass: Option<usize>,
186 #[serde(skip_serializing_if = "Option::is_none")]
187 pub check_fail: Option<usize>,
188 #[serde(skip_serializing_if = "Option::is_none")]
189 pub check_validation_fail: Option<usize>,
190 #[serde(skip_serializing_if = "Option::is_none")]
191 pub check_runner_fail: Option<usize>,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
195pub struct CubeOrder {
196 pub actions: &'static str,
197 pub asserts: &'static str,
198 pub cases: &'static str,
199 #[serde(skip_serializing_if = "is_default_report_check_order")]
200 pub cmake_checks: &'static str,
201 pub dimensions: &'static str,
202 pub telemetry: &'static str,
203}
204
205fn is_default_report_check_order(value: &&'static str) -> bool {
206 *value == "report_check_order"
207}
208
209#[derive(Debug, Clone, PartialEq, Serialize)]
210pub struct CubeDimensions {
211 pub action_kind: Vec<String>,
212 pub action_status: Vec<ActionStatus>,
213 pub assert_status: Vec<Status>,
214 #[serde(skip_serializing_if = "Vec::is_empty")]
215 pub branch: Vec<String>,
216 pub case_status: Vec<Status>,
217 #[serde(skip_serializing_if = "Vec::is_empty")]
218 pub cmake_check_fail_class: Vec<CmakeCheckFailClass>,
219 #[serde(skip_serializing_if = "Vec::is_empty")]
220 pub cmake_check_kind: Vec<String>,
221 #[serde(skip_serializing_if = "Vec::is_empty")]
222 pub cmake_check_status: Vec<CmakeCheckStatus>,
223 #[serde(skip_serializing_if = "Vec::is_empty")]
224 pub cmake_configuration_id: Vec<String>,
225 #[serde(skip_serializing_if = "Vec::is_empty")]
226 pub cmake_target_name: Vec<String>,
227 #[serde(skip_serializing_if = "Vec::is_empty")]
228 pub commit: Vec<String>,
229 pub item_id: Vec<String>,
230 #[serde(skip_serializing_if = "Vec::is_empty")]
231 pub job_id: Vec<String>,
232 #[serde(skip_serializing_if = "Vec::is_empty")]
233 pub pipeline_id: Vec<String>,
234 #[serde(skip_serializing_if = "Vec::is_empty")]
235 pub runner: Vec<String>,
236 pub telemetry_kind: Vec<String>,
237 pub telemetry_name: Vec<String>,
238 pub telemetry_unit: Vec<String>,
239 pub test_name: Vec<String>,
240}
241
242#[derive(Debug, Clone, PartialEq, Serialize)]
243pub struct CubeFacts {
244 pub cases: Vec<CubeCaseRow>,
245 pub actions: Vec<CubeActionRow>,
246 pub asserts: Vec<CubeAssertRow>,
247 #[serde(skip_serializing_if = "Vec::is_empty")]
248 pub cmake_checks: Vec<CubeCmakeCheckRow>,
249 pub telemetry: Vec<CubeTelemetryRow>,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct CubeCmakeCheckRow {
254 pub row_ix: usize,
255 pub check_id: String,
256 pub check_kind: String,
257 pub status: CmakeCheckStatus,
258 #[serde(skip_serializing_if = "Option::is_none")]
259 pub fail_class: Option<CmakeCheckFailClass>,
260 #[serde(skip_serializing_if = "Option::is_none")]
261 pub msg: Option<String>,
262 #[serde(skip_serializing_if = "Vec::is_empty")]
263 pub checked_targets: Vec<CmakeTargetRef>,
264 #[serde(skip_serializing_if = "Vec::is_empty")]
265 pub missing_artifacts: Vec<CmakeMissingArtifact>,
266}
267
268#[derive(Debug, Clone, PartialEq, Serialize)]
269pub struct CubeCaseRow {
270 pub row_ix: usize,
271 pub case_id: String,
272 pub item_id: String,
273 pub test_name: String,
274 pub status: Status,
275 pub assert_pass: usize,
276 pub assert_fail: usize,
277 pub unhandled_action_fail: usize,
278}
279
280#[derive(Debug, Clone, PartialEq, Serialize)]
281pub struct CubeActionRow {
282 pub row_ix: usize,
283 pub case_id: String,
284 pub item_id: String,
285 pub test_name: String,
286 pub action_ix: usize,
287 pub action: String,
288 pub status: ActionStatus,
289 #[serde(skip_serializing_if = "Option::is_none")]
290 pub fail_kind: Option<String>,
291 #[serde(skip_serializing_if = "Option::is_none")]
292 pub fail_code: Option<String>,
293 #[serde(skip_serializing_if = "Option::is_none")]
294 pub exit: Option<i64>,
295 #[serde(skip_serializing_if = "Option::is_none")]
296 pub out_len: Option<u64>,
297 #[serde(skip_serializing_if = "Option::is_none")]
298 pub err_len: Option<u64>,
299 #[serde(skip_serializing_if = "Option::is_none")]
300 pub body_len: Option<u64>,
301 #[serde(skip_serializing_if = "Option::is_none")]
302 pub bytes_len: Option<u64>,
303}
304
305#[derive(Debug, Clone, PartialEq, Serialize)]
306pub struct CubeAssertRow {
307 pub row_ix: usize,
308 pub case_id: String,
309 pub item_id: String,
310 pub test_name: String,
311 pub assert_ix: usize,
312 pub status: Status,
313 pub msg: String,
314}
315
316#[derive(Debug, Clone, PartialEq, Serialize)]
317pub struct CubeTelemetryRow {
318 pub row_ix: usize,
319 pub case_id: String,
320 pub item_id: String,
321 pub test_name: String,
322 #[serde(skip_serializing_if = "Option::is_none")]
323 pub action_ix: Option<usize>,
324 pub scope: TelemetryScope,
325 pub name: String,
326 pub kind: &'static str,
327 #[serde(skip_serializing_if = "Option::is_none")]
328 pub unit: Option<String>,
329 #[serde(skip_serializing_if = "Option::is_none")]
330 pub value: Option<Number>,
331 #[serde(skip_serializing_if = "Option::is_none")]
332 pub values: Option<Vec<Number>>,
333 #[serde(skip_serializing_if = "Option::is_none")]
334 pub tag_value: Option<String>,
335 pub canonical: bool,
336}
337
338#[derive(Debug, Clone, PartialEq, Serialize)]
339pub struct CubeMeasures {
340 pub action_fail: usize,
341 pub action_total: usize,
342 pub assert_fail: usize,
343 pub assert_pass: usize,
344 pub assert_total: usize,
345 pub case_fail: usize,
346 pub case_pass: usize,
347 pub case_total: usize,
348 #[serde(skip_serializing_if = "is_zero")]
349 pub cmake_check_fail: usize,
350 #[serde(skip_serializing_if = "is_zero")]
351 pub cmake_check_pass: usize,
352 #[serde(skip_serializing_if = "is_zero")]
353 pub cmake_check_runner_fail: usize,
354 #[serde(skip_serializing_if = "is_zero")]
355 pub cmake_check_total: usize,
356 #[serde(skip_serializing_if = "is_zero")]
357 pub cmake_check_validation_fail: usize,
358 #[serde(skip_serializing_if = "is_zero")]
359 pub cmake_missing_artifact_total: usize,
360 pub fail_by_action_kind: BTreeMap<String, usize>,
361 pub fail_by_item_id: BTreeMap<String, usize>,
362 pub metric_summary: BTreeMap<String, MetricSummary>,
363 pub telemetry_total: usize,
364}
365
366fn is_zero(value: &usize) -> bool {
367 *value == 0
368}
369
370#[derive(Debug, Clone, PartialEq, Serialize)]
371pub struct MetricSummary {
372 pub avg: f64,
373 pub count: usize,
374 pub max: Number,
375 pub min: Number,
376 pub unit: String,
377}
378
379#[derive(Debug, Clone, PartialEq, Serialize)]
380pub struct BuildCompare {
381 pub k: &'static str,
382 pub v: &'static str,
383 pub builds: Vec<CompareBuildEntry>,
384 pub relations: Vec<BuildRelation>,
385 pub comparisons: CompareTables,
386 pub measures: CompareMeasures,
387}
388
389#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
390pub struct CompareBuildEntry {
391 pub build_id: String,
392 #[serde(skip_serializing_if = "Option::is_none")]
393 pub inventory_sha256: Option<String>,
394 #[serde(skip_serializing_if = "Option::is_none")]
395 pub suite_sha256: Option<String>,
396 #[serde(skip_serializing_if = "Option::is_none")]
397 pub model_sha256: Option<String>,
398 #[serde(skip_serializing_if = "Option::is_none")]
399 pub report_sha256: Option<String>,
400 #[serde(skip_serializing_if = "Option::is_none")]
401 pub runner_kind: Option<String>,
402 pub report_mode: ReportMode,
403 pub meta: BuildMeta,
404}
405
406#[derive(Debug, Clone, PartialEq, Serialize)]
407pub struct CompareTables {
408 pub action_fail_change: Vec<ActionFailChangeRow>,
409 pub case_status_change: Vec<CaseStatusChangeRow>,
410 pub metric_delta: Vec<MetricDeltaRow>,
411 pub missing_or_added_tests: Vec<MissingOrAddedTestRow>,
412}
413
414#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
415pub struct ActionFailChangeRow {
416 pub action: String,
417 pub baseline_fail: usize,
418 pub candidate_fail: usize,
419 pub delta: i64,
420 pub item_id: String,
421 pub test_name: String,
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
425pub struct CaseStatusChangeRow {
426 pub baseline_status: CompareStatus,
427 pub candidate_status: CompareStatus,
428 pub category: CaseChangeCategory,
429 pub item_id: String,
430 pub test_name: String,
431}
432
433#[derive(Debug, Clone, PartialEq, Serialize)]
434pub struct MetricDeltaRow {
435 pub baseline_value: Number,
436 pub candidate_value: Number,
437 pub delta: Number,
438 #[serde(skip_serializing_if = "Option::is_none")]
439 pub delta_ratio: Option<f64>,
440 pub item_id: String,
441 pub name: String,
442 pub test_name: String,
443 pub unit: String,
444}
445
446#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
447pub struct MissingOrAddedTestRow {
448 pub category: PresenceCategory,
449 pub item_id: String,
450 pub test_name: String,
451}
452
453#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
454pub struct CompareMeasures {
455 pub added_tests: usize,
456 pub build_count: usize,
457 pub fail_to_pass: usize,
458 pub pair_count: usize,
459 pub pass_to_fail: usize,
460 pub removed_tests: usize,
461}
462
463#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
464pub struct BuildCompareIndex {
465 pub k: &'static str,
466 pub v: &'static str,
467 pub builds: Vec<CompareBuildEntry>,
468 pub relations: Vec<BuildRelation>,
469 pub pairs: Vec<CompareIndexPair>,
470 pub measures: CompareIndexMeasures,
471}
472
473#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
474pub struct CompareIndexPair {
475 pub baseline_build_id: String,
476 pub candidate_build_id: String,
477 pub compare_artifact: String,
478}
479
480#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
481pub struct CompareIndexMeasures {
482 pub build_count: usize,
483 pub pair_count: usize,
484 pub relation_count: usize,
485}
486
487#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
488#[serde(rename_all = "snake_case")]
489pub enum CompareStatus {
490 Pass,
491 Fail,
492 Missing,
493}
494
495#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
496#[serde(rename_all = "snake_case")]
497pub enum CaseChangeCategory {
498 PassToPass,
499 PassToFail,
500 FailToPass,
501 FailToFail,
502 Added,
503 Removed,
504}
505
506#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
507#[serde(rename_all = "snake_case")]
508pub enum PresenceCategory {
509 Added,
510 Removed,
511}
512
513#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
514#[serde(rename_all = "snake_case")]
515pub enum RelationKind {
516 Previous,
517 Parent,
518 Child,
519 Shard,
520 Subbuild,
521}
522
523impl RelationKind {
524 fn parse(value: &str) -> AnalyticsResult<Self> {
525 match value {
526 "previous" => Ok(Self::Previous),
527 "parent" => Ok(Self::Parent),
528 "child" => Ok(Self::Child),
529 "shard" => Ok(Self::Shard),
530 "subbuild" => Ok(Self::Subbuild),
531 _ => Err(AnalyticsError::new(
532 AnalyticsErrorCode::UnsupportedRelationKind,
533 format!("unsupported relation_kind `{value}`"),
534 )),
535 }
536 }
537}
538
539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
540pub struct BuildRelation {
541 pub from_build_id: String,
542 pub relation_kind: RelationKind,
543 pub to_build_id: String,
544}
545
546#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
547pub struct CompareMeta {
548 pub relations: Vec<BuildRelation>,
549}
550
551pub fn serialize_analytics_json<T: Serialize>(value: &T) -> AnalyticsResult<String> {
552 let mut out = Vec::new();
553 let formatter = serde_json::ser::PrettyFormatter::with_indent(b"\t");
554 let mut serializer = serde_json::Serializer::with_formatter(&mut out, formatter);
555 value.serialize(&mut serializer).map_err(|error| {
556 AnalyticsError::new(
557 AnalyticsErrorCode::InvalidReportShape,
558 format!("failed to serialize analytics artifact: {error}"),
559 )
560 })?;
561 String::from_utf8(out).map_err(|error| {
562 AnalyticsError::new(
563 AnalyticsErrorCode::InvalidReportShape,
564 format!("failed to build analytics json string: {error}"),
565 )
566 })
567}
568
569pub fn derive_cube_from_report(
570 records: &[ReportRecord],
571 build_meta: Option<BuildMeta>,
572) -> AnalyticsResult<BuildCube> {
573 validate_report_shape(records)?;
574
575 let header = records
576 .iter()
577 .find_map(|record| match record {
578 ReportRecord::Header(header) => Some(header.clone()),
579 _ => None,
580 })
581 .ok_or_else(|| AnalyticsError::new(AnalyticsErrorCode::InvalidReportShape, "report missing header record"))?;
582
583 let case_rows = records
584 .iter()
585 .filter_map(|record| match record {
586 ReportRecord::Case(case) => Some(case),
587 _ => None,
588 })
589 .enumerate()
590 .map(|(row_ix, case)| CubeCaseRow {
591 row_ix,
592 case_id: case.case_id.clone(),
593 item_id: case.item_id.clone(),
594 test_name: case.test_name.clone(),
595 status: case.status,
596 assert_pass: case.assert_pass,
597 assert_fail: case.assert_fail,
598 unhandled_action_fail: case.unhandled_action_fail,
599 })
600 .collect::<Vec<_>>();
601
602 let case_lookup = case_rows
603 .iter()
604 .map(|row| (row.case_id.clone(), (row.item_id.clone(), row.test_name.clone())))
605 .collect::<BTreeMap<_, _>>();
606
607 let action_rows = records
608 .iter()
609 .filter_map(|record| match record {
610 ReportRecord::Action(action) => Some(action),
611 _ => None,
612 })
613 .enumerate()
614 .map(|(row_ix, action)| {
615 let (item_id, test_name) = lookup_case_identity(&case_lookup, &action.case_id)?;
616 Ok(CubeActionRow {
617 row_ix,
618 case_id: action.case_id.clone(),
619 item_id,
620 test_name,
621 action_ix: action.action_ix,
622 action: action.action.clone(),
623 status: action.status,
624 fail_kind: action.fail.as_ref().map(|value| value.kind.clone()),
625 fail_code: action.fail.as_ref().and_then(|value| value.code.clone()),
626 exit: json_i64(action.ok.as_ref(), "exit"),
627 out_len: json_u64(action.ok.as_ref(), "out_len"),
628 err_len: json_u64(action.ok.as_ref(), "err_len"),
629 body_len: json_u64(action.ok.as_ref(), "body_len"),
630 bytes_len: json_u64(action.ok.as_ref(), "bytes_len"),
631 })
632 })
633 .collect::<AnalyticsResult<Vec<_>>>()?;
634
635 let assert_rows = records
636 .iter()
637 .filter_map(|record| match record {
638 ReportRecord::Assert(assert) => Some(assert),
639 _ => None,
640 })
641 .enumerate()
642 .map(|(row_ix, assert)| {
643 let (item_id, test_name) = lookup_case_identity(&case_lookup, &assert.case_id)?;
644 Ok(CubeAssertRow {
645 row_ix,
646 case_id: assert.case_id.clone(),
647 item_id,
648 test_name,
649 assert_ix: assert.assert_ix,
650 status: assert.status,
651 msg: assert.msg.clone(),
652 })
653 })
654 .collect::<AnalyticsResult<Vec<_>>>()?;
655
656 let telemetry_rows = records
657 .iter()
658 .filter_map(|record| match record {
659 ReportRecord::Telemetry(telemetry) => Some(telemetry),
660 _ => None,
661 })
662 .enumerate()
663 .map(|(row_ix, telemetry)| {
664 let (item_id, test_name) = lookup_case_identity(&case_lookup, &telemetry.case_id)?;
665 let (kind, value, values, tag_value) = match &telemetry.entry.value {
666 TelemetryValue::Metric { value } => ("metric", Some(value.clone()), None, None),
667 TelemetryValue::Vector { values } => ("vector", None, Some(values.clone()), None),
668 TelemetryValue::Tag { value } => ("tag", None, None, Some(value.clone())),
669 };
670 Ok(CubeTelemetryRow {
671 row_ix,
672 case_id: telemetry.case_id.clone(),
673 item_id,
674 test_name,
675 action_ix: telemetry.action_ix,
676 scope: telemetry.scope,
677 name: telemetry.entry.name.clone(),
678 kind,
679 unit: telemetry.entry.unit.clone(),
680 value,
681 values,
682 tag_value,
683 canonical: telemetry.canonical,
684 })
685 })
686 .collect::<AnalyticsResult<Vec<_>>>()?;
687
688 let measures = derive_measures(&case_rows, &action_rows, &assert_rows, &[], &telemetry_rows)?;
689 let meta = build_meta.unwrap_or_default();
690
691 Ok(BuildCube {
692 k: "observer_build_cube",
693 v: "0",
694 build: CubeBuild {
695 build_id: meta.build_id.clone(),
696 inventory_sha256: Some(header.inventory_sha256),
697 suite_sha256: Some(header.suite_sha256),
698 model_sha256: None,
699 report_sha256: None,
700 report_mode: header.mode,
701 meta: meta.clone(),
702 },
703 stage: None,
704 order: CubeOrder {
705 actions: "report_case_order_then_action_ix",
706 asserts: "report_case_order_then_assert_ix",
707 cases: "report_case_order",
708 cmake_checks: "report_check_order",
709 dimensions: "utf8_lexicographic",
710 telemetry: "report_case_order_then_action_ix_then_record_order",
711 },
712 dimensions: derive_dimensions(&case_rows, &action_rows, &assert_rows, &telemetry_rows, &meta, &[]),
713 facts: CubeFacts {
714 cases: case_rows,
715 actions: action_rows,
716 asserts: assert_rows,
717 cmake_checks: Vec::new(),
718 telemetry: telemetry_rows,
719 },
720 measures,
721 })
722}
723
724pub fn derive_cube_from_cmake_report(
725 records: &[CmakeModelReportRecord],
726 stage: &ProductStageRecord,
727 report_mode: ReportMode,
728 build_meta: Option<BuildMeta>,
729) -> AnalyticsResult<BuildCube> {
730 let (header, summary) = validate_cmake_report_shape(records)?;
731 let meta = build_meta.unwrap_or_default();
732 let cmake_check_rows = records
733 .iter()
734 .filter_map(|record| match record {
735 CmakeModelReportRecord::Check(check) => Some(check),
736 _ => None,
737 })
738 .enumerate()
739 .map(|(row_ix, check)| {
740 Ok(CubeCmakeCheckRow {
741 row_ix,
742 check_id: check.check_id.clone(),
743 check_kind: check.check_kind.clone(),
744 status: check.status,
745 fail_class: check.fail.as_ref().map(|fail| fail.class),
746 msg: check.fail.as_ref().map(|fail| fail.msg.clone()),
747 checked_targets: extract_checked_targets(check)?,
748 missing_artifacts: check
749 .fail
750 .as_ref()
751 .map(|fail| fail.missing_artifacts.clone())
752 .unwrap_or_default(),
753 })
754 })
755 .collect::<AnalyticsResult<Vec<_>>>()?;
756 let measures = derive_measures(&[], &[], &[], &cmake_check_rows, &[])?;
757
758 if measures.cmake_check_pass != summary.check_pass
759 || measures.cmake_check_fail != summary.check_fail
760 || measures.cmake_check_validation_fail != summary.check_validation_fail
761 || measures.cmake_check_runner_fail != summary.check_runner_fail
762 {
763 return Err(AnalyticsError::new(
764 AnalyticsErrorCode::InvalidReportShape,
765 "cmake summary counters did not match cmake_check rows",
766 ));
767 }
768
769 Ok(BuildCube {
770 k: "observer_build_cube",
771 v: "0",
772 build: CubeBuild {
773 build_id: meta.build_id.clone(),
774 inventory_sha256: stage.child_inventory_sha256.clone(),
775 suite_sha256: None,
776 model_sha256: Some(header.model_sha256.clone()),
777 report_sha256: stage.child_report_sha256.clone(),
778 report_mode,
779 meta: meta.clone(),
780 },
781 stage: Some(cube_stage_from_product_stage(stage)),
782 order: CubeOrder {
783 actions: "report_case_order_then_action_ix",
784 asserts: "report_case_order_then_assert_ix",
785 cases: "report_case_order",
786 cmake_checks: "report_check_order",
787 dimensions: "utf8_lexicographic",
788 telemetry: "report_case_order_then_action_ix_then_record_order",
789 },
790 dimensions: derive_dimensions(&[], &[], &[], &[], &meta, &cmake_check_rows),
791 facts: CubeFacts {
792 cases: Vec::new(),
793 actions: Vec::new(),
794 asserts: Vec::new(),
795 cmake_checks: cmake_check_rows,
796 telemetry: Vec::new(),
797 },
798 measures,
799 })
800}
801
802pub fn apply_product_stage_metadata(cube: &mut BuildCube, stage: &ProductStageRecord) {
803 cube.build.report_sha256 = stage.child_report_sha256.clone();
804 cube.build.model_sha256 = stage.child_model_sha256.clone();
805 cube.stage = Some(cube_stage_from_product_stage(stage));
806}
807
808pub fn derive_cube_from_product_stage(
809 stage: &ProductStageRecord,
810 report_mode: ReportMode,
811 build_meta: Option<BuildMeta>,
812) -> AnalyticsResult<BuildCube> {
813 let meta = build_meta.unwrap_or_default();
814 Ok(BuildCube {
815 k: "observer_build_cube",
816 v: "0",
817 build: CubeBuild {
818 build_id: meta.build_id.clone(),
819 inventory_sha256: stage.child_inventory_sha256.clone(),
820 suite_sha256: stage.child_suite_sha256.clone(),
821 model_sha256: stage.child_model_sha256.clone(),
822 report_sha256: stage.child_report_sha256.clone(),
823 report_mode,
824 meta: meta.clone(),
825 },
826 stage: Some(cube_stage_from_product_stage(stage)),
827 order: CubeOrder {
828 actions: "report_case_order_then_action_ix",
829 asserts: "report_case_order_then_assert_ix",
830 cases: "report_case_order",
831 cmake_checks: "report_check_order",
832 dimensions: "utf8_lexicographic",
833 telemetry: "report_case_order_then_action_ix_then_record_order",
834 },
835 dimensions: empty_cube_dimensions(&meta),
836 facts: CubeFacts {
837 cases: Vec::new(),
838 actions: Vec::new(),
839 asserts: Vec::new(),
840 cmake_checks: Vec::new(),
841 telemetry: Vec::new(),
842 },
843 measures: empty_cube_measures(),
844 })
845}
846
847pub fn parse_build_cube_json(source: &str) -> AnalyticsResult<BuildCube> {
848 let parsed: ParsedBuildCube = serde_json::from_str(source).map_err(|error| {
849 AnalyticsError::new(
850 AnalyticsErrorCode::InvalidCubeShape,
851 format!("invalid cube json: {error}"),
852 )
853 })?;
854 if parsed.k != "observer_build_cube" {
855 return Err(AnalyticsError::new(
856 AnalyticsErrorCode::InvalidCubeShape,
857 format!("unsupported cube kind `{}`", parsed.k),
858 ));
859 }
860 if parsed.v != "0" {
861 return Err(AnalyticsError::new(
862 AnalyticsErrorCode::InvalidCubeShape,
863 format!("unsupported cube version `{}`", parsed.v),
864 ));
865 }
866
867 let build_id = parsed.build.build_id.ok_or_else(|| {
868 AnalyticsError::new(
869 AnalyticsErrorCode::MissingBuildId,
870 "source cube missing build.build_id",
871 )
872 })?;
873
874 let meta = parsed.build.meta;
875 let case_rows = parsed
876 .facts
877 .cases
878 .into_iter()
879 .map(|row| CubeCaseRow {
880 row_ix: row.row_ix,
881 case_id: row.case_id,
882 item_id: row.item_id,
883 test_name: row.test_name,
884 status: row.status,
885 assert_pass: row.assert_pass,
886 assert_fail: row.assert_fail,
887 unhandled_action_fail: row.unhandled_action_fail,
888 })
889 .collect::<Vec<_>>();
890 let action_rows = parsed
891 .facts
892 .actions
893 .into_iter()
894 .map(|row| CubeActionRow {
895 row_ix: row.row_ix,
896 case_id: row.case_id,
897 item_id: row.item_id,
898 test_name: row.test_name,
899 action_ix: row.action_ix,
900 action: row.action,
901 status: row.status,
902 fail_kind: row.fail_kind,
903 fail_code: row.fail_code,
904 exit: row.exit,
905 out_len: row.out_len,
906 err_len: row.err_len,
907 body_len: row.body_len,
908 bytes_len: row.bytes_len,
909 })
910 .collect::<Vec<_>>();
911 let assert_rows = parsed
912 .facts
913 .asserts
914 .into_iter()
915 .map(|row| CubeAssertRow {
916 row_ix: row.row_ix,
917 case_id: row.case_id,
918 item_id: row.item_id,
919 test_name: row.test_name,
920 assert_ix: row.assert_ix,
921 status: row.status,
922 msg: row.msg,
923 })
924 .collect::<Vec<_>>();
925 let telemetry_rows = parsed
926 .facts
927 .telemetry
928 .into_iter()
929 .map(|row| {
930 Ok(CubeTelemetryRow {
931 row_ix: row.row_ix,
932 case_id: row.case_id,
933 item_id: row.item_id,
934 test_name: row.test_name,
935 action_ix: row.action_ix,
936 scope: row.scope,
937 name: row.name,
938 kind: parse_cube_telemetry_kind(&row.kind)?,
939 unit: row.unit,
940 value: row.value,
941 values: row.values,
942 tag_value: row.tag_value,
943 canonical: row.canonical,
944 })
945 })
946 .collect::<AnalyticsResult<Vec<_>>>()?;
947 let cmake_check_rows = parsed.facts.cmake_checks.into_iter().map(|row| CubeCmakeCheckRow {
948 row_ix: row.row_ix,
949 check_id: row.check_id,
950 check_kind: row.check_kind,
951 status: row.status,
952 fail_class: row.fail_class,
953 msg: row.msg,
954 checked_targets: row.checked_targets,
955 missing_artifacts: row.missing_artifacts,
956 }).collect::<Vec<_>>();
957
958 let order = if let Some(order) = parsed.order {
959 CubeOrder {
960 actions: static_cube_order_field(&order.actions)?,
961 asserts: static_cube_order_field(&order.asserts)?,
962 cases: static_cube_order_field(&order.cases)?,
963 cmake_checks: static_cube_order_field(&order.cmake_checks)?,
964 dimensions: static_cube_order_field(&order.dimensions)?,
965 telemetry: static_cube_order_field(&order.telemetry)?,
966 }
967 } else {
968 CubeOrder {
969 actions: "report_case_order_then_action_ix",
970 asserts: "report_case_order_then_assert_ix",
971 cases: "report_case_order",
972 cmake_checks: "report_check_order",
973 dimensions: "utf8_lexicographic",
974 telemetry: "report_case_order_then_action_ix_then_record_order",
975 }
976 };
977
978 let dimensions = if let Some(dimensions) = parsed.dimensions {
979 CubeDimensions {
980 action_kind: dimensions.action_kind,
981 action_status: dimensions.action_status,
982 assert_status: dimensions.assert_status,
983 branch: dimensions.branch,
984 case_status: dimensions.case_status,
985 cmake_check_fail_class: dimensions.cmake_check_fail_class,
986 cmake_check_kind: dimensions.cmake_check_kind,
987 cmake_check_status: dimensions.cmake_check_status,
988 cmake_configuration_id: dimensions.cmake_configuration_id,
989 cmake_target_name: dimensions.cmake_target_name,
990 commit: dimensions.commit,
991 item_id: dimensions.item_id,
992 job_id: dimensions.job_id,
993 pipeline_id: dimensions.pipeline_id,
994 runner: dimensions.runner,
995 telemetry_kind: dimensions.telemetry_kind,
996 telemetry_name: dimensions.telemetry_name,
997 telemetry_unit: dimensions.telemetry_unit,
998 test_name: dimensions.test_name,
999 }
1000 } else {
1001 derive_dimensions(&case_rows, &action_rows, &assert_rows, &telemetry_rows, &meta, &cmake_check_rows)
1002 };
1003
1004 let measures = if let Some(measures) = parsed.measures {
1005 CubeMeasures {
1006 action_fail: measures.action_fail,
1007 action_total: measures.action_total,
1008 assert_fail: measures.assert_fail,
1009 assert_pass: measures.assert_pass,
1010 assert_total: measures.assert_total,
1011 case_fail: measures.case_fail,
1012 case_pass: measures.case_pass,
1013 case_total: measures.case_total,
1014 cmake_check_fail: measures.cmake_check_fail,
1015 cmake_check_pass: measures.cmake_check_pass,
1016 cmake_check_runner_fail: measures.cmake_check_runner_fail,
1017 cmake_check_total: measures.cmake_check_total,
1018 cmake_check_validation_fail: measures.cmake_check_validation_fail,
1019 cmake_missing_artifact_total: measures.cmake_missing_artifact_total,
1020 fail_by_action_kind: measures.fail_by_action_kind,
1021 fail_by_item_id: measures.fail_by_item_id,
1022 metric_summary: measures
1023 .metric_summary
1024 .into_iter()
1025 .map(|(name, summary)| {
1026 (
1027 name,
1028 MetricSummary {
1029 avg: summary.avg,
1030 count: summary.count,
1031 max: summary.max,
1032 min: summary.min,
1033 unit: summary.unit,
1034 },
1035 )
1036 })
1037 .collect(),
1038 telemetry_total: measures.telemetry_total,
1039 }
1040 } else {
1041 derive_measures(&case_rows, &action_rows, &assert_rows, &cmake_check_rows, &telemetry_rows)?
1042 };
1043
1044 Ok(BuildCube {
1045 k: "observer_build_cube",
1046 v: "0",
1047 build: CubeBuild {
1048 build_id: Some(build_id),
1049 inventory_sha256: parsed.build.inventory_sha256,
1050 suite_sha256: parsed.build.suite_sha256,
1051 model_sha256: parsed.build.model_sha256,
1052 report_sha256: parsed.build.report_sha256,
1053 report_mode: parsed.build.report_mode,
1054 meta,
1055 },
1056 stage: parsed.stage.map(|stage| CubeStage {
1057 runner_kind: stage.runner_kind,
1058 status: stage.status,
1059 exit_code: stage.exit_code,
1060 required: stage.required,
1061 case_pass: stage.case_pass,
1062 case_fail: stage.case_fail,
1063 assert_pass: stage.assert_pass,
1064 assert_fail: stage.assert_fail,
1065 check_pass: stage.check_pass,
1066 check_fail: stage.check_fail,
1067 check_validation_fail: stage.check_validation_fail,
1068 check_runner_fail: stage.check_runner_fail,
1069 }),
1070 order,
1071 dimensions,
1072 facts: CubeFacts {
1073 cases: case_rows,
1074 actions: action_rows,
1075 asserts: assert_rows,
1076 cmake_checks: cmake_check_rows,
1077 telemetry: telemetry_rows,
1078 },
1079 measures,
1080 })
1081}
1082
1083pub fn parse_compare_meta_json(source: &str) -> AnalyticsResult<CompareMeta> {
1084 let value: Value = serde_json::from_str(source).map_err(|error| {
1085 AnalyticsError::new(
1086 AnalyticsErrorCode::InvalidCompareMeta,
1087 format!("invalid compare metadata json: {error}"),
1088 )
1089 })?;
1090 let object = value.as_object().ok_or_else(|| {
1091 AnalyticsError::new(
1092 AnalyticsErrorCode::InvalidCompareMeta,
1093 "compare metadata must be a json object",
1094 )
1095 })?;
1096 if object.len() != 1 || !object.contains_key("relations") {
1097 return Err(AnalyticsError::new(
1098 AnalyticsErrorCode::InvalidCompareMeta,
1099 "compare metadata must contain only `relations`",
1100 ));
1101 }
1102 let relations = object
1103 .get("relations")
1104 .and_then(Value::as_array)
1105 .ok_or_else(|| {
1106 AnalyticsError::new(
1107 AnalyticsErrorCode::InvalidCompareMeta,
1108 "compare metadata `relations` must be an array",
1109 )
1110 })?;
1111
1112 let mut parsed_relations = Vec::with_capacity(relations.len());
1113 for relation in relations {
1114 let relation_object = relation.as_object().ok_or_else(|| {
1115 AnalyticsError::new(
1116 AnalyticsErrorCode::InvalidCompareMeta,
1117 "relation row must be a json object",
1118 )
1119 })?;
1120 if relation_object.len() != 3 {
1121 return Err(AnalyticsError::new(
1122 AnalyticsErrorCode::InvalidCompareMeta,
1123 "relation row must contain exactly from_build_id, relation_kind, and to_build_id",
1124 ));
1125 }
1126 let from_build_id = relation_object
1127 .get("from_build_id")
1128 .and_then(Value::as_str)
1129 .ok_or_else(|| {
1130 AnalyticsError::new(
1131 AnalyticsErrorCode::InvalidCompareMeta,
1132 "relation row missing string from_build_id",
1133 )
1134 })?;
1135 let to_build_id = relation_object
1136 .get("to_build_id")
1137 .and_then(Value::as_str)
1138 .ok_or_else(|| {
1139 AnalyticsError::new(
1140 AnalyticsErrorCode::InvalidCompareMeta,
1141 "relation row missing string to_build_id",
1142 )
1143 })?;
1144 let relation_kind = relation_object
1145 .get("relation_kind")
1146 .and_then(Value::as_str)
1147 .ok_or_else(|| {
1148 AnalyticsError::new(
1149 AnalyticsErrorCode::InvalidCompareMeta,
1150 "relation row missing string relation_kind",
1151 )
1152 })?;
1153
1154 parsed_relations.push(BuildRelation {
1155 from_build_id: from_build_id.to_owned(),
1156 relation_kind: RelationKind::parse(relation_kind)?,
1157 to_build_id: to_build_id.to_owned(),
1158 });
1159 }
1160
1161 Ok(CompareMeta {
1162 relations: parsed_relations,
1163 })
1164}
1165
1166pub fn parse_build_compare_json(source: &str) -> AnalyticsResult<BuildCompare> {
1167 let parsed: ParsedBuildCompare = serde_json::from_str(source).map_err(|error| {
1168 AnalyticsError::new(
1169 AnalyticsErrorCode::InvalidCompareShape,
1170 format!("invalid compare json: {error}"),
1171 )
1172 })?;
1173 if parsed.k != "observer_build_compare" {
1174 return Err(AnalyticsError::new(
1175 AnalyticsErrorCode::InvalidCompareShape,
1176 format!("unsupported compare kind `{}`", parsed.k),
1177 ));
1178 }
1179 if parsed.v != "0" {
1180 return Err(AnalyticsError::new(
1181 AnalyticsErrorCode::InvalidCompareShape,
1182 format!("unsupported compare version `{}`", parsed.v),
1183 ));
1184 }
1185 Ok(BuildCompare {
1186 k: "observer_build_compare",
1187 v: "0",
1188 builds: parsed
1189 .builds
1190 .into_iter()
1191 .map(|build| CompareBuildEntry {
1192 build_id: build.build_id,
1193 inventory_sha256: build.inventory_sha256,
1194 suite_sha256: build.suite_sha256,
1195 model_sha256: build.model_sha256,
1196 report_sha256: build.report_sha256,
1197 runner_kind: build.runner_kind,
1198 report_mode: build.report_mode,
1199 meta: build.meta,
1200 })
1201 .collect(),
1202 relations: parsed
1203 .relations
1204 .into_iter()
1205 .map(|relation| BuildRelation {
1206 from_build_id: relation.from_build_id,
1207 relation_kind: relation.relation_kind,
1208 to_build_id: relation.to_build_id,
1209 })
1210 .collect(),
1211 comparisons: CompareTables {
1212 action_fail_change: parsed
1213 .comparisons
1214 .action_fail_change
1215 .into_iter()
1216 .map(|row| ActionFailChangeRow {
1217 action: row.action,
1218 baseline_fail: row.baseline_fail,
1219 candidate_fail: row.candidate_fail,
1220 delta: row.delta,
1221 item_id: row.item_id,
1222 test_name: row.test_name,
1223 })
1224 .collect(),
1225 case_status_change: parsed
1226 .comparisons
1227 .case_status_change
1228 .into_iter()
1229 .map(|row| CaseStatusChangeRow {
1230 baseline_status: row.baseline_status,
1231 candidate_status: row.candidate_status,
1232 category: row.category,
1233 item_id: row.item_id,
1234 test_name: row.test_name,
1235 })
1236 .collect(),
1237 metric_delta: parsed
1238 .comparisons
1239 .metric_delta
1240 .into_iter()
1241 .map(|row| MetricDeltaRow {
1242 baseline_value: row.baseline_value,
1243 candidate_value: row.candidate_value,
1244 delta: row.delta,
1245 delta_ratio: row.delta_ratio,
1246 item_id: row.item_id,
1247 name: row.name,
1248 test_name: row.test_name,
1249 unit: row.unit,
1250 })
1251 .collect(),
1252 missing_or_added_tests: parsed
1253 .comparisons
1254 .missing_or_added_tests
1255 .into_iter()
1256 .map(|row| MissingOrAddedTestRow {
1257 category: row.category,
1258 item_id: row.item_id,
1259 test_name: row.test_name,
1260 })
1261 .collect(),
1262 },
1263 measures: CompareMeasures {
1264 added_tests: parsed.measures.added_tests,
1265 build_count: parsed.measures.build_count,
1266 fail_to_pass: parsed.measures.fail_to_pass,
1267 pair_count: parsed.measures.pair_count,
1268 pass_to_fail: parsed.measures.pass_to_fail,
1269 removed_tests: parsed.measures.removed_tests,
1270 },
1271 })
1272}
1273
1274pub fn parse_build_compare_index_json(source: &str) -> AnalyticsResult<BuildCompareIndex> {
1275 let parsed: ParsedBuildCompareIndex = serde_json::from_str(source).map_err(|error| {
1276 AnalyticsError::new(
1277 AnalyticsErrorCode::InvalidCompareIndexShape,
1278 format!("invalid compare-index json: {error}"),
1279 )
1280 })?;
1281 if parsed.k != "observer_build_compare_index" {
1282 return Err(AnalyticsError::new(
1283 AnalyticsErrorCode::InvalidCompareIndexShape,
1284 format!("unsupported compare-index kind `{}`", parsed.k),
1285 ));
1286 }
1287 if parsed.v != "0" {
1288 return Err(AnalyticsError::new(
1289 AnalyticsErrorCode::InvalidCompareIndexShape,
1290 format!("unsupported compare-index version `{}`", parsed.v),
1291 ));
1292 }
1293 Ok(BuildCompareIndex {
1294 k: "observer_build_compare_index",
1295 v: "0",
1296 builds: parsed
1297 .builds
1298 .into_iter()
1299 .map(|build| CompareBuildEntry {
1300 build_id: build.build_id,
1301 inventory_sha256: build.inventory_sha256,
1302 suite_sha256: build.suite_sha256,
1303 model_sha256: build.model_sha256,
1304 report_sha256: build.report_sha256,
1305 runner_kind: build.runner_kind,
1306 report_mode: build.report_mode,
1307 meta: build.meta,
1308 })
1309 .collect(),
1310 relations: parsed
1311 .relations
1312 .into_iter()
1313 .map(|relation| BuildRelation {
1314 from_build_id: relation.from_build_id,
1315 relation_kind: relation.relation_kind,
1316 to_build_id: relation.to_build_id,
1317 })
1318 .collect(),
1319 pairs: parsed
1320 .pairs
1321 .into_iter()
1322 .map(|pair| CompareIndexPair {
1323 baseline_build_id: pair.baseline_build_id,
1324 candidate_build_id: pair.candidate_build_id,
1325 compare_artifact: pair.compare_artifact,
1326 })
1327 .collect(),
1328 measures: CompareIndexMeasures {
1329 build_count: parsed.measures.build_count,
1330 pair_count: parsed.measures.pair_count,
1331 relation_count: parsed.measures.relation_count,
1332 },
1333 })
1334}
1335
1336pub fn derive_compare_pair(
1337 baseline: &BuildCube,
1338 candidate: &BuildCube,
1339 compare_meta: Option<CompareMeta>,
1340) -> AnalyticsResult<BuildCompare> {
1341 let baseline_id = baseline.build.build_id.clone().ok_or_else(|| {
1342 AnalyticsError::new(
1343 AnalyticsErrorCode::MissingBuildId,
1344 "baseline cube missing build.build_id",
1345 )
1346 })?;
1347 let candidate_id = candidate.build.build_id.clone().ok_or_else(|| {
1348 AnalyticsError::new(
1349 AnalyticsErrorCode::MissingBuildId,
1350 "candidate cube missing build.build_id",
1351 )
1352 })?;
1353 if baseline_id == candidate_id {
1354 return Err(AnalyticsError::new(
1355 AnalyticsErrorCode::DuplicateBuildId,
1356 format!("pairwise comparison requires distinct build ids, found `{baseline_id}` twice"),
1357 ));
1358 }
1359
1360 let builds = vec![
1361 CompareBuildEntry {
1362 build_id: baseline_id.clone(),
1363 inventory_sha256: baseline.build.inventory_sha256.clone(),
1364 suite_sha256: baseline.build.suite_sha256.clone(),
1365 model_sha256: baseline.build.model_sha256.clone(),
1366 report_sha256: baseline.build.report_sha256.clone(),
1367 runner_kind: baseline.stage.as_ref().map(|stage| stage.runner_kind.clone()),
1368 report_mode: baseline.build.report_mode,
1369 meta: baseline.build.meta.clone(),
1370 },
1371 CompareBuildEntry {
1372 build_id: candidate_id.clone(),
1373 inventory_sha256: candidate.build.inventory_sha256.clone(),
1374 suite_sha256: candidate.build.suite_sha256.clone(),
1375 model_sha256: candidate.build.model_sha256.clone(),
1376 report_sha256: candidate.build.report_sha256.clone(),
1377 runner_kind: candidate.stage.as_ref().map(|stage| stage.runner_kind.clone()),
1378 report_mode: candidate.build.report_mode,
1379 meta: candidate.build.meta.clone(),
1380 },
1381 ];
1382
1383 let mut relations = compare_meta.map(|meta| meta.relations).unwrap_or_default();
1384 let known_build_ids = [baseline_id.clone(), candidate_id.clone()]
1385 .into_iter()
1386 .collect::<BTreeSet<_>>();
1387 for relation in &relations {
1388 if !known_build_ids.contains(&relation.from_build_id)
1389 || !known_build_ids.contains(&relation.to_build_id)
1390 {
1391 return Err(AnalyticsError::new(
1392 AnalyticsErrorCode::UnknownRelationBuild,
1393 "relation metadata references build id not present in input cubes",
1394 ));
1395 }
1396 }
1397 relations.sort_by(|left, right| {
1398 left.from_build_id
1399 .cmp(&right.from_build_id)
1400 .then(relation_kind_key(left.relation_kind).cmp(relation_kind_key(right.relation_kind)))
1401 .then(left.to_build_id.cmp(&right.to_build_id))
1402 });
1403
1404 let baseline_cases = baseline
1405 .facts
1406 .cases
1407 .iter()
1408 .map(|row| ((row.item_id.clone(), row.test_name.clone()), row.status))
1409 .collect::<BTreeMap<_, _>>();
1410 let candidate_cases = candidate
1411 .facts
1412 .cases
1413 .iter()
1414 .map(|row| ((row.item_id.clone(), row.test_name.clone()), row.status))
1415 .collect::<BTreeMap<_, _>>();
1416
1417 let mut all_case_keys = BTreeSet::new();
1418 all_case_keys.extend(baseline_cases.keys().cloned());
1419 all_case_keys.extend(candidate_cases.keys().cloned());
1420
1421 let mut case_status_change = Vec::new();
1422 let mut missing_or_added_tests = Vec::new();
1423 let mut pass_to_fail = 0usize;
1424 let mut fail_to_pass = 0usize;
1425 let mut added_tests = 0usize;
1426 let mut removed_tests = 0usize;
1427
1428 for (item_id, test_name) in all_case_keys {
1429 let baseline_status = baseline_cases.get(&(item_id.clone(), test_name.clone())).copied();
1430 let candidate_status = candidate_cases.get(&(item_id.clone(), test_name.clone())).copied();
1431 let baseline_compare = compare_status(baseline_status);
1432 let candidate_compare = compare_status(candidate_status);
1433 let category = classify_case_change(baseline_compare, candidate_compare);
1434
1435 match category {
1436 CaseChangeCategory::PassToFail => pass_to_fail += 1,
1437 CaseChangeCategory::FailToPass => fail_to_pass += 1,
1438 CaseChangeCategory::Added => {
1439 added_tests += 1;
1440 missing_or_added_tests.push(MissingOrAddedTestRow {
1441 category: PresenceCategory::Added,
1442 item_id: item_id.clone(),
1443 test_name: test_name.clone(),
1444 });
1445 }
1446 CaseChangeCategory::Removed => {
1447 removed_tests += 1;
1448 missing_or_added_tests.push(MissingOrAddedTestRow {
1449 category: PresenceCategory::Removed,
1450 item_id: item_id.clone(),
1451 test_name: test_name.clone(),
1452 });
1453 }
1454 CaseChangeCategory::PassToPass | CaseChangeCategory::FailToFail => {}
1455 }
1456
1457 case_status_change.push(CaseStatusChangeRow {
1458 baseline_status: baseline_compare,
1459 candidate_status: candidate_compare,
1460 category,
1461 item_id,
1462 test_name,
1463 });
1464 }
1465
1466 let baseline_action_fails = aggregate_action_failures(&baseline.facts.actions);
1467 let candidate_action_fails = aggregate_action_failures(&candidate.facts.actions);
1468 let mut action_keys = BTreeSet::new();
1469 action_keys.extend(baseline_action_fails.keys().cloned());
1470 action_keys.extend(candidate_action_fails.keys().cloned());
1471 let mut action_fail_change = Vec::new();
1472 for (item_id, test_name, action) in action_keys {
1473 let baseline_fail = *baseline_action_fails
1474 .get(&(item_id.clone(), test_name.clone(), action.clone()))
1475 .unwrap_or(&0);
1476 let candidate_fail = *candidate_action_fails
1477 .get(&(item_id.clone(), test_name.clone(), action.clone()))
1478 .unwrap_or(&0);
1479 if baseline_fail == candidate_fail {
1480 continue;
1481 }
1482 action_fail_change.push(ActionFailChangeRow {
1483 action,
1484 baseline_fail,
1485 candidate_fail,
1486 delta: candidate_fail as i64 - baseline_fail as i64,
1487 item_id,
1488 test_name,
1489 });
1490 }
1491
1492 let baseline_metrics = aggregate_metric_values(&baseline.facts.telemetry)?;
1493 let candidate_metrics = aggregate_metric_values(&candidate.facts.telemetry)?;
1494 let mut metric_keys = BTreeSet::new();
1495 metric_keys.extend(baseline_metrics.keys().cloned());
1496 metric_keys.extend(candidate_metrics.keys().cloned());
1497 let mut metric_delta = Vec::new();
1498 for (item_id, test_name, name, unit) in metric_keys {
1499 let Some(baseline_value) = baseline_metrics
1500 .get(&(item_id.clone(), test_name.clone(), name.clone(), unit.clone()))
1501 .cloned()
1502 else {
1503 continue;
1504 };
1505 let Some(candidate_value) = candidate_metrics
1506 .get(&(item_id.clone(), test_name.clone(), name.clone(), unit.clone()))
1507 .cloned()
1508 else {
1509 continue;
1510 };
1511 let baseline_f64 = number_to_f64(&baseline_value)?;
1512 let candidate_f64 = number_to_f64(&candidate_value)?;
1513 let delta_f64 = candidate_f64 - baseline_f64;
1514 if delta_f64 == 0.0 {
1515 continue;
1516 }
1517 metric_delta.push(MetricDeltaRow {
1518 baseline_value,
1519 candidate_value,
1520 delta: number_from_f64(delta_f64)?,
1521 delta_ratio: if baseline_f64 == 0.0 {
1522 None
1523 } else {
1524 Some(delta_f64 / baseline_f64)
1525 },
1526 item_id,
1527 name,
1528 test_name,
1529 unit,
1530 });
1531 }
1532
1533 Ok(BuildCompare {
1534 k: "observer_build_compare",
1535 v: "0",
1536 builds,
1537 relations,
1538 comparisons: CompareTables {
1539 action_fail_change,
1540 case_status_change,
1541 metric_delta,
1542 missing_or_added_tests,
1543 },
1544 measures: CompareMeasures {
1545 added_tests,
1546 build_count: 2,
1547 fail_to_pass,
1548 pair_count: 1,
1549 pass_to_fail,
1550 removed_tests,
1551 },
1552 })
1553}
1554
1555pub fn derive_compare_index(
1556 cubes: &[BuildCube],
1557 compare_meta: Option<CompareMeta>,
1558) -> AnalyticsResult<BuildCompareIndex> {
1559 if cubes.is_empty() {
1560 return Err(AnalyticsError::new(
1561 AnalyticsErrorCode::EmptyCubeDir,
1562 "compare directory contained no cube artifacts",
1563 ));
1564 }
1565
1566 let mut builds = cubes
1567 .iter()
1568 .map(|cube| {
1569 let build_id = cube.build.build_id.clone().ok_or_else(|| {
1570 AnalyticsError::new(
1571 AnalyticsErrorCode::MissingBuildId,
1572 "source cube missing build.build_id",
1573 )
1574 })?;
1575 Ok(CompareBuildEntry {
1576 build_id,
1577 inventory_sha256: cube.build.inventory_sha256.clone(),
1578 suite_sha256: cube.build.suite_sha256.clone(),
1579 model_sha256: cube.build.model_sha256.clone(),
1580 report_sha256: cube.build.report_sha256.clone(),
1581 runner_kind: cube.stage.as_ref().map(|stage| stage.runner_kind.clone()),
1582 report_mode: cube.build.report_mode,
1583 meta: cube.build.meta.clone(),
1584 })
1585 })
1586 .collect::<AnalyticsResult<Vec<_>>>()?;
1587 builds.sort_by(|left, right| left.build_id.cmp(&right.build_id));
1588
1589 for pair in builds.windows(2) {
1590 if pair[0].build_id == pair[1].build_id {
1591 return Err(AnalyticsError::new(
1592 AnalyticsErrorCode::DuplicateBuildId,
1593 format!("duplicate build id `{}` in compare index input", pair[0].build_id),
1594 ));
1595 }
1596 }
1597
1598 let build_ids = builds
1599 .iter()
1600 .map(|build| build.build_id.clone())
1601 .collect::<BTreeSet<_>>();
1602 let mut relations = compare_meta.map(|meta| meta.relations).unwrap_or_default();
1603 for relation in &relations {
1604 if !build_ids.contains(&relation.from_build_id) || !build_ids.contains(&relation.to_build_id) {
1605 return Err(AnalyticsError::new(
1606 AnalyticsErrorCode::UnknownRelationBuild,
1607 "relation metadata references build id not present in input cubes",
1608 ));
1609 }
1610 }
1611 relations.sort_by(|left, right| {
1612 left.from_build_id
1613 .cmp(&right.from_build_id)
1614 .then(relation_kind_key(left.relation_kind).cmp(relation_kind_key(right.relation_kind)))
1615 .then(left.to_build_id.cmp(&right.to_build_id))
1616 });
1617
1618 let previous_pairs = relations
1619 .iter()
1620 .filter(|relation| relation.relation_kind == RelationKind::Previous)
1621 .map(|relation| (relation.from_build_id.clone(), relation.to_build_id.clone()))
1622 .collect::<Vec<_>>();
1623
1624 let pair_keys = if previous_pairs.is_empty() {
1625 builds
1626 .windows(2)
1627 .map(|pair| (pair[0].build_id.clone(), pair[1].build_id.clone()))
1628 .collect::<Vec<_>>()
1629 } else {
1630 previous_pairs
1631 };
1632
1633 let pairs = pair_keys
1634 .into_iter()
1635 .map(|(baseline_build_id, candidate_build_id)| CompareIndexPair {
1636 compare_artifact: format!(
1637 "compare-{}-{}.json",
1638 compare_artifact_build_component(&baseline_build_id),
1639 compare_artifact_build_component(&candidate_build_id)
1640 ),
1641 baseline_build_id,
1642 candidate_build_id,
1643 })
1644 .collect::<Vec<_>>();
1645
1646 let relation_count = relations.len();
1647
1648 Ok(BuildCompareIndex {
1649 k: "observer_build_compare_index",
1650 v: "0",
1651 builds,
1652 relations,
1653 measures: CompareIndexMeasures {
1654 build_count: cubes.len(),
1655 pair_count: pairs.len(),
1656 relation_count,
1657 },
1658 pairs,
1659 })
1660}
1661
1662fn validate_report_shape(records: &[ReportRecord]) -> AnalyticsResult<()> {
1663 let header_count = records.iter().filter(|record| matches!(record, ReportRecord::Header(_))).count();
1664 let summary_count = records.iter().filter(|record| matches!(record, ReportRecord::Summary(_))).count();
1665 if header_count != 1 {
1666 return Err(AnalyticsError::new(
1667 AnalyticsErrorCode::InvalidReportShape,
1668 format!("report must contain exactly one header record, found {header_count}"),
1669 ));
1670 }
1671 if summary_count != 1 {
1672 return Err(AnalyticsError::new(
1673 AnalyticsErrorCode::InvalidReportShape,
1674 format!("report must contain exactly one final summary record, found {summary_count}"),
1675 ));
1676 }
1677 if !matches!(records.first(), Some(ReportRecord::Header(_))) {
1678 return Err(AnalyticsError::new(
1679 AnalyticsErrorCode::InvalidReportShape,
1680 "report must begin with header record",
1681 ));
1682 }
1683 if !matches!(records.last(), Some(ReportRecord::Summary(_))) {
1684 return Err(AnalyticsError::new(
1685 AnalyticsErrorCode::InvalidReportShape,
1686 "report missing final summary record",
1687 ));
1688 }
1689 Ok(())
1690}
1691
1692fn lookup_case_identity(
1693 lookup: &BTreeMap<String, (String, String)>,
1694 case_id: &str,
1695) -> AnalyticsResult<(String, String)> {
1696 lookup
1697 .get(case_id)
1698 .cloned()
1699 .ok_or_else(|| AnalyticsError::new(AnalyticsErrorCode::InvalidCaseIdentity, format!("unknown case id `{case_id}`")))
1700}
1701
1702fn json_i64(value: Option<&Value>, key: &str) -> Option<i64> {
1703 value?.get(key)?.as_i64()
1704}
1705
1706fn json_u64(value: Option<&Value>, key: &str) -> Option<u64> {
1707 value?.get(key)?.as_u64()
1708}
1709
1710fn derive_dimensions(
1711 cases: &[CubeCaseRow],
1712 actions: &[CubeActionRow],
1713 asserts: &[CubeAssertRow],
1714 telemetry: &[CubeTelemetryRow],
1715 meta: &BuildMeta,
1716 cmake_checks: &[CubeCmakeCheckRow],
1717) -> CubeDimensions {
1718 CubeDimensions {
1719 action_kind: unique_sorted(actions.iter().map(|row| row.action.clone())),
1720 action_status: unique_sorted_by(actions.iter().map(|row| row.status), action_status_key),
1721 assert_status: unique_sorted_by(asserts.iter().map(|row| row.status), status_key),
1722 branch: optional_dimension(meta.branch.clone()),
1723 case_status: unique_sorted_by(cases.iter().map(|row| row.status), status_key),
1724 cmake_check_fail_class: unique_sorted_by(
1725 cmake_checks.iter().filter_map(|row| row.fail_class),
1726 cmake_fail_class_key,
1727 ),
1728 cmake_check_kind: unique_sorted(cmake_checks.iter().map(|row| row.check_kind.clone())),
1729 cmake_check_status: unique_sorted_by(
1730 cmake_checks.iter().map(|row| row.status),
1731 cmake_check_status_key,
1732 ),
1733 cmake_configuration_id: unique_sorted(cmake_checks.iter().flat_map(|row| {
1734 row.checked_targets
1735 .iter()
1736 .map(|target| target.configuration_id.clone())
1737 .chain(row.missing_artifacts.iter().map(|artifact| artifact.target.configuration_id.clone()))
1738 })),
1739 cmake_target_name: unique_sorted(cmake_checks.iter().flat_map(|row| {
1740 row.checked_targets
1741 .iter()
1742 .map(|target| target.target_name.clone())
1743 .chain(row.missing_artifacts.iter().map(|artifact| artifact.target.target_name.clone()))
1744 })),
1745 commit: optional_dimension(meta.commit.clone()),
1746 item_id: unique_sorted(cases.iter().map(|row| row.item_id.clone())),
1747 job_id: optional_dimension(meta.job_id.clone()),
1748 pipeline_id: optional_dimension(meta.pipeline_id.clone()),
1749 runner: optional_dimension(meta.runner.clone()),
1750 telemetry_kind: unique_sorted(telemetry.iter().map(|row| row.kind.to_owned())),
1751 telemetry_name: unique_sorted(telemetry.iter().map(|row| row.name.clone())),
1752 telemetry_unit: unique_sorted(telemetry.iter().filter_map(|row| row.unit.clone())),
1753 test_name: unique_sorted(cases.iter().map(|row| row.test_name.clone())),
1754 }
1755}
1756
1757fn optional_dimension(value: Option<String>) -> Vec<String> {
1758 value.into_iter().collect()
1759}
1760
1761fn unique_sorted<T>(items: impl Iterator<Item = T>) -> Vec<T>
1762where
1763 T: Ord,
1764{
1765 let mut set = BTreeSet::new();
1766 set.extend(items);
1767 set.into_iter().collect()
1768}
1769
1770fn unique_sorted_by<T, K>(items: impl Iterator<Item = T>, key_fn: fn(&T) -> K) -> Vec<T>
1771where
1772 K: Ord,
1773{
1774 let mut values = items.collect::<Vec<_>>();
1775 values.sort_by_key(|item| key_fn(item));
1776 values.dedup_by(|left, right| key_fn(left) == key_fn(right));
1777 values
1778}
1779
1780fn status_key(status: &Status) -> &'static str {
1781 match status {
1782 Status::Fail => "fail",
1783 Status::Pass => "pass",
1784 }
1785}
1786
1787fn action_status_key(status: &ActionStatus) -> &'static str {
1788 match status {
1789 ActionStatus::Fail => "fail",
1790 ActionStatus::Ok => "ok",
1791 }
1792}
1793
1794fn cmake_check_status_key(status: &CmakeCheckStatus) -> &'static str {
1795 match status {
1796 CmakeCheckStatus::Fail => "fail",
1797 CmakeCheckStatus::Pass => "pass",
1798 CmakeCheckStatus::RunnerFail => "runner_fail",
1799 CmakeCheckStatus::ValidationFail => "validation_fail",
1800 }
1801}
1802
1803fn cmake_fail_class_key(class: &CmakeCheckFailClass) -> &'static str {
1804 match class {
1805 CmakeCheckFailClass::CertificationFailure => "certification_failure",
1806 CmakeCheckFailClass::RunnerFailure => "runner_failure",
1807 CmakeCheckFailClass::ValidationFailure => "validation_failure",
1808 }
1809}
1810
1811fn derive_measures(
1812 cases: &[CubeCaseRow],
1813 actions: &[CubeActionRow],
1814 asserts: &[CubeAssertRow],
1815 cmake_checks: &[CubeCmakeCheckRow],
1816 telemetry: &[CubeTelemetryRow],
1817) -> AnalyticsResult<CubeMeasures> {
1818 let mut fail_by_action_kind = BTreeMap::new();
1819 for row in actions {
1820 let entry = fail_by_action_kind.entry(row.action.clone()).or_insert(0);
1821 if row.status == ActionStatus::Fail {
1822 *entry += 1;
1823 }
1824 }
1825
1826 let mut fail_by_item_id = BTreeMap::new();
1827 for row in cases {
1828 let entry = fail_by_item_id.entry(row.item_id.clone()).or_insert(0);
1829 if row.status == Status::Fail {
1830 *entry += 1;
1831 }
1832 }
1833
1834 let mut metric_samples = BTreeMap::<String, (String, Vec<Number>)>::new();
1835 for row in telemetry {
1836 if row.kind != "metric" {
1837 continue;
1838 }
1839 let unit = row.unit.clone().unwrap_or_default();
1840 let value = row.value.clone().ok_or_else(|| {
1841 AnalyticsError::new(
1842 AnalyticsErrorCode::InvalidTelemetryShape,
1843 format!("metric telemetry `{}` missing numeric value", row.name),
1844 )
1845 })?;
1846 let entry = metric_samples.entry(row.name.clone()).or_insert_with(|| (unit.clone(), Vec::new()));
1847 if entry.0 != unit {
1848 return Err(AnalyticsError::new(
1849 AnalyticsErrorCode::InvalidTelemetryShape,
1850 format!("metric telemetry `{}` used multiple units", row.name),
1851 ));
1852 }
1853 entry.1.push(value);
1854 }
1855
1856 let metric_summary = metric_samples
1857 .into_iter()
1858 .map(|(name, (unit, values))| {
1859 let floats = values
1860 .iter()
1861 .map(number_to_f64)
1862 .collect::<AnalyticsResult<Vec<_>>>()?;
1863 let avg = floats.iter().sum::<f64>() / floats.len() as f64;
1864 let min = values
1865 .iter()
1866 .min_by(|left, right| compare_numbers(left, right))
1867 .cloned()
1868 .unwrap();
1869 let max = values
1870 .iter()
1871 .max_by(|left, right| compare_numbers(left, right))
1872 .cloned()
1873 .unwrap();
1874 Ok((
1875 name,
1876 MetricSummary {
1877 avg,
1878 count: values.len(),
1879 max,
1880 min,
1881 unit,
1882 },
1883 ))
1884 })
1885 .collect::<AnalyticsResult<BTreeMap<_, _>>>()?;
1886
1887 Ok(CubeMeasures {
1888 action_fail: actions.iter().filter(|row| row.status == ActionStatus::Fail).count(),
1889 action_total: actions.len(),
1890 assert_fail: asserts.iter().filter(|row| row.status == Status::Fail).count(),
1891 assert_pass: asserts.iter().filter(|row| row.status == Status::Pass).count(),
1892 assert_total: asserts.len(),
1893 case_fail: cases.iter().filter(|row| row.status == Status::Fail).count(),
1894 case_pass: cases.iter().filter(|row| row.status == Status::Pass).count(),
1895 case_total: cases.len(),
1896 cmake_check_fail: cmake_checks
1897 .iter()
1898 .filter(|row| row.status == CmakeCheckStatus::Fail)
1899 .count(),
1900 cmake_check_pass: cmake_checks
1901 .iter()
1902 .filter(|row| row.status == CmakeCheckStatus::Pass)
1903 .count(),
1904 cmake_check_runner_fail: cmake_checks
1905 .iter()
1906 .filter(|row| row.status == CmakeCheckStatus::RunnerFail)
1907 .count(),
1908 cmake_check_total: cmake_checks.len(),
1909 cmake_check_validation_fail: cmake_checks
1910 .iter()
1911 .filter(|row| row.status == CmakeCheckStatus::ValidationFail)
1912 .count(),
1913 cmake_missing_artifact_total: cmake_checks
1914 .iter()
1915 .map(|row| row.missing_artifacts.len())
1916 .sum(),
1917 fail_by_action_kind,
1918 fail_by_item_id,
1919 metric_summary,
1920 telemetry_total: telemetry.len(),
1921 })
1922}
1923
1924fn number_to_f64(value: &Number) -> AnalyticsResult<f64> {
1925 value.as_f64().ok_or_else(|| {
1926 AnalyticsError::new(
1927 AnalyticsErrorCode::InvalidTelemetryShape,
1928 "metric telemetry value must be numeric",
1929 )
1930 })
1931}
1932
1933fn compare_numbers(left: &Number, right: &Number) -> std::cmp::Ordering {
1934 let left = left.as_f64().unwrap_or(0.0);
1935 let right = right.as_f64().unwrap_or(0.0);
1936 left.partial_cmp(&right).unwrap_or(std::cmp::Ordering::Equal)
1937}
1938
1939fn number_from_f64(value: f64) -> AnalyticsResult<Number> {
1940 if value.fract() == 0.0 && value >= i64::MIN as f64 && value <= i64::MAX as f64 {
1941 return Ok(Number::from(value as i64));
1942 }
1943 Number::from_f64(value).ok_or_else(|| {
1944 AnalyticsError::new(
1945 AnalyticsErrorCode::InvalidTelemetryShape,
1946 "metric delta produced non-finite number",
1947 )
1948 })
1949}
1950
1951fn compare_status(status: Option<Status>) -> CompareStatus {
1952 match status {
1953 Some(Status::Pass) => CompareStatus::Pass,
1954 Some(Status::Fail) => CompareStatus::Fail,
1955 None => CompareStatus::Missing,
1956 }
1957}
1958
1959fn classify_case_change(baseline: CompareStatus, candidate: CompareStatus) -> CaseChangeCategory {
1960 match (baseline, candidate) {
1961 (CompareStatus::Pass, CompareStatus::Pass) => CaseChangeCategory::PassToPass,
1962 (CompareStatus::Pass, CompareStatus::Fail) => CaseChangeCategory::PassToFail,
1963 (CompareStatus::Fail, CompareStatus::Pass) => CaseChangeCategory::FailToPass,
1964 (CompareStatus::Fail, CompareStatus::Fail) => CaseChangeCategory::FailToFail,
1965 (CompareStatus::Missing, _) => CaseChangeCategory::Added,
1966 (_, CompareStatus::Missing) => CaseChangeCategory::Removed,
1967 }
1968}
1969
1970fn aggregate_action_failures(actions: &[CubeActionRow]) -> BTreeMap<(String, String, String), usize> {
1971 let mut out = BTreeMap::new();
1972 for action in actions {
1973 if action.status != ActionStatus::Fail {
1974 continue;
1975 }
1976 *out.entry((action.item_id.clone(), action.test_name.clone(), action.action.clone()))
1977 .or_insert(0) += 1;
1978 }
1979 out
1980}
1981
1982fn aggregate_metric_values(
1983 telemetry: &[CubeTelemetryRow],
1984) -> AnalyticsResult<BTreeMap<(String, String, String, String), Number>> {
1985 let mut out = BTreeMap::new();
1986 for row in telemetry {
1987 if row.kind != "metric" {
1988 continue;
1989 }
1990 let unit = row.unit.clone().ok_or_else(|| {
1991 AnalyticsError::new(
1992 AnalyticsErrorCode::InvalidCubeShape,
1993 format!("metric telemetry `{}` missing unit in cube artifact", row.name),
1994 )
1995 })?;
1996 let value = row.value.clone().ok_or_else(|| {
1997 AnalyticsError::new(
1998 AnalyticsErrorCode::InvalidCubeShape,
1999 format!("metric telemetry `{}` missing value in cube artifact", row.name),
2000 )
2001 })?;
2002 out.insert(
2003 (
2004 row.item_id.clone(),
2005 row.test_name.clone(),
2006 row.name.clone(),
2007 unit,
2008 ),
2009 value,
2010 );
2011 }
2012 Ok(out)
2013}
2014
2015fn relation_kind_key(kind: RelationKind) -> &'static str {
2016 match kind {
2017 RelationKind::Child => "child",
2018 RelationKind::Parent => "parent",
2019 RelationKind::Previous => "previous",
2020 RelationKind::Shard => "shard",
2021 RelationKind::Subbuild => "subbuild",
2022 }
2023}
2024
2025fn cube_stage_from_product_stage(stage: &ProductStageRecord) -> CubeStage {
2026 CubeStage {
2027 runner_kind: stage.runner_kind.clone(),
2028 status: stage.status,
2029 exit_code: stage.exit_code,
2030 required: stage.required,
2031 case_pass: stage.child_case_pass,
2032 case_fail: stage.child_case_fail,
2033 assert_pass: stage.child_assert_pass,
2034 assert_fail: stage.child_assert_fail,
2035 check_pass: stage.child_check_pass,
2036 check_fail: stage.child_check_fail,
2037 check_validation_fail: stage.child_check_validation_fail,
2038 check_runner_fail: stage.child_check_runner_fail,
2039 }
2040}
2041
2042fn empty_cube_dimensions(meta: &BuildMeta) -> CubeDimensions {
2043 CubeDimensions {
2044 action_kind: Vec::new(),
2045 action_status: Vec::new(),
2046 assert_status: Vec::new(),
2047 branch: meta.branch.clone().into_iter().collect(),
2048 case_status: Vec::new(),
2049 cmake_check_fail_class: Vec::new(),
2050 cmake_check_kind: Vec::new(),
2051 cmake_check_status: Vec::new(),
2052 cmake_configuration_id: Vec::new(),
2053 cmake_target_name: Vec::new(),
2054 commit: meta.commit.clone().into_iter().collect(),
2055 item_id: Vec::new(),
2056 job_id: meta.job_id.clone().into_iter().collect(),
2057 pipeline_id: meta.pipeline_id.clone().into_iter().collect(),
2058 runner: meta.runner.clone().into_iter().collect(),
2059 telemetry_kind: Vec::new(),
2060 telemetry_name: Vec::new(),
2061 telemetry_unit: Vec::new(),
2062 test_name: Vec::new(),
2063 }
2064}
2065
2066fn empty_cube_measures() -> CubeMeasures {
2067 CubeMeasures {
2068 action_fail: 0,
2069 action_total: 0,
2070 assert_fail: 0,
2071 assert_pass: 0,
2072 assert_total: 0,
2073 case_fail: 0,
2074 case_pass: 0,
2075 case_total: 0,
2076 cmake_check_fail: 0,
2077 cmake_check_pass: 0,
2078 cmake_check_runner_fail: 0,
2079 cmake_check_total: 0,
2080 cmake_check_validation_fail: 0,
2081 cmake_missing_artifact_total: 0,
2082 fail_by_action_kind: BTreeMap::new(),
2083 fail_by_item_id: BTreeMap::new(),
2084 metric_summary: BTreeMap::new(),
2085 telemetry_total: 0,
2086 }
2087}
2088
2089fn compare_artifact_build_component(build_id: &str) -> &str {
2090 build_id.strip_prefix("build-").unwrap_or(build_id)
2091}
2092
2093#[derive(Debug, Deserialize)]
2094struct ParsedBuildCube {
2095 k: String,
2096 v: String,
2097 build: ParsedCubeBuild,
2098 #[serde(default)]
2099 stage: Option<ParsedCubeStage>,
2100 #[serde(default)]
2101 order: Option<ParsedCubeOrder>,
2102 #[serde(default)]
2103 dimensions: Option<ParsedCubeDimensions>,
2104 facts: ParsedCubeFacts,
2105 #[serde(default)]
2106 measures: Option<ParsedCubeMeasures>,
2107}
2108
2109#[derive(Debug, Deserialize)]
2110struct ParsedCubeOrder {
2111 actions: String,
2112 asserts: String,
2113 cases: String,
2114 #[serde(default = "default_report_check_order")]
2115 cmake_checks: String,
2116 dimensions: String,
2117 telemetry: String,
2118}
2119
2120fn default_report_check_order() -> String {
2121 "report_check_order".to_owned()
2122}
2123
2124#[derive(Debug, Deserialize)]
2125struct ParsedCubeDimensions {
2126 action_kind: Vec<String>,
2127 action_status: Vec<ActionStatus>,
2128 assert_status: Vec<Status>,
2129 #[serde(default)]
2130 branch: Vec<String>,
2131 case_status: Vec<Status>,
2132 #[serde(default)]
2133 cmake_check_fail_class: Vec<CmakeCheckFailClass>,
2134 #[serde(default)]
2135 cmake_check_kind: Vec<String>,
2136 #[serde(default)]
2137 cmake_check_status: Vec<CmakeCheckStatus>,
2138 #[serde(default)]
2139 cmake_configuration_id: Vec<String>,
2140 #[serde(default)]
2141 cmake_target_name: Vec<String>,
2142 #[serde(default)]
2143 commit: Vec<String>,
2144 item_id: Vec<String>,
2145 #[serde(default)]
2146 job_id: Vec<String>,
2147 #[serde(default)]
2148 pipeline_id: Vec<String>,
2149 #[serde(default)]
2150 runner: Vec<String>,
2151 telemetry_kind: Vec<String>,
2152 telemetry_name: Vec<String>,
2153 telemetry_unit: Vec<String>,
2154 test_name: Vec<String>,
2155}
2156
2157#[derive(Debug, Deserialize)]
2158struct ParsedCubeMeasures {
2159 action_fail: usize,
2160 action_total: usize,
2161 assert_fail: usize,
2162 assert_pass: usize,
2163 assert_total: usize,
2164 case_fail: usize,
2165 case_pass: usize,
2166 case_total: usize,
2167 #[serde(default)]
2168 cmake_check_fail: usize,
2169 #[serde(default)]
2170 cmake_check_pass: usize,
2171 #[serde(default)]
2172 cmake_check_runner_fail: usize,
2173 #[serde(default)]
2174 cmake_check_total: usize,
2175 #[serde(default)]
2176 cmake_check_validation_fail: usize,
2177 #[serde(default)]
2178 cmake_missing_artifact_total: usize,
2179 fail_by_action_kind: BTreeMap<String, usize>,
2180 fail_by_item_id: BTreeMap<String, usize>,
2181 metric_summary: BTreeMap<String, ParsedMetricSummary>,
2182 telemetry_total: usize,
2183}
2184
2185#[derive(Debug, Deserialize)]
2186struct ParsedMetricSummary {
2187 avg: f64,
2188 count: usize,
2189 max: Number,
2190 min: Number,
2191 unit: String,
2192}
2193
2194#[derive(Debug, Deserialize)]
2195struct ParsedBuildCompare {
2196 k: String,
2197 v: String,
2198 builds: Vec<ParsedCompareBuildEntry>,
2199 relations: Vec<ParsedBuildRelation>,
2200 comparisons: ParsedCompareTables,
2201 measures: ParsedCompareMeasures,
2202}
2203
2204#[derive(Debug, Deserialize)]
2205struct ParsedCompareBuildEntry {
2206 build_id: String,
2207 #[serde(default)]
2208 inventory_sha256: Option<String>,
2209 #[serde(default)]
2210 suite_sha256: Option<String>,
2211 #[serde(default)]
2212 model_sha256: Option<String>,
2213 #[serde(default)]
2214 report_sha256: Option<String>,
2215 #[serde(default)]
2216 runner_kind: Option<String>,
2217 report_mode: ReportMode,
2218 meta: BuildMeta,
2219}
2220
2221#[derive(Debug, Deserialize)]
2222struct ParsedCompareTables {
2223 action_fail_change: Vec<ParsedActionFailChangeRow>,
2224 case_status_change: Vec<ParsedCaseStatusChangeRow>,
2225 metric_delta: Vec<ParsedMetricDeltaRow>,
2226 missing_or_added_tests: Vec<ParsedMissingOrAddedTestRow>,
2227}
2228
2229#[derive(Debug, Deserialize)]
2230struct ParsedActionFailChangeRow {
2231 action: String,
2232 baseline_fail: usize,
2233 candidate_fail: usize,
2234 delta: i64,
2235 item_id: String,
2236 test_name: String,
2237}
2238
2239#[derive(Debug, Deserialize)]
2240struct ParsedCaseStatusChangeRow {
2241 baseline_status: CompareStatus,
2242 candidate_status: CompareStatus,
2243 category: CaseChangeCategory,
2244 item_id: String,
2245 test_name: String,
2246}
2247
2248#[derive(Debug, Deserialize)]
2249struct ParsedMetricDeltaRow {
2250 baseline_value: Number,
2251 candidate_value: Number,
2252 delta: Number,
2253 delta_ratio: Option<f64>,
2254 item_id: String,
2255 name: String,
2256 test_name: String,
2257 unit: String,
2258}
2259
2260#[derive(Debug, Deserialize)]
2261struct ParsedMissingOrAddedTestRow {
2262 category: PresenceCategory,
2263 item_id: String,
2264 test_name: String,
2265}
2266
2267#[derive(Debug, Deserialize)]
2268struct ParsedCompareMeasures {
2269 added_tests: usize,
2270 build_count: usize,
2271 fail_to_pass: usize,
2272 pair_count: usize,
2273 pass_to_fail: usize,
2274 removed_tests: usize,
2275}
2276
2277#[derive(Debug, Deserialize)]
2278struct ParsedBuildCompareIndex {
2279 k: String,
2280 v: String,
2281 builds: Vec<ParsedCompareBuildEntry>,
2282 relations: Vec<ParsedBuildRelation>,
2283 pairs: Vec<ParsedCompareIndexPair>,
2284 measures: ParsedCompareIndexMeasures,
2285}
2286
2287#[derive(Debug, Deserialize)]
2288struct ParsedCompareIndexPair {
2289 baseline_build_id: String,
2290 candidate_build_id: String,
2291 compare_artifact: String,
2292}
2293
2294#[derive(Debug, Deserialize)]
2295struct ParsedCompareIndexMeasures {
2296 build_count: usize,
2297 pair_count: usize,
2298 relation_count: usize,
2299}
2300
2301#[derive(Debug, Deserialize)]
2302struct ParsedBuildRelation {
2303 from_build_id: String,
2304 relation_kind: RelationKind,
2305 to_build_id: String,
2306}
2307
2308#[derive(Debug, Deserialize)]
2309struct ParsedCubeBuild {
2310 build_id: Option<String>,
2311 #[serde(default)]
2312 inventory_sha256: Option<String>,
2313 #[serde(default)]
2314 suite_sha256: Option<String>,
2315 #[serde(default)]
2316 model_sha256: Option<String>,
2317 #[serde(default)]
2318 report_sha256: Option<String>,
2319 report_mode: ReportMode,
2320 meta: BuildMeta,
2321}
2322
2323#[derive(Debug, Deserialize)]
2324struct ParsedCubeStage {
2325 runner_kind: String,
2326 status: ProductStatus,
2327 exit_code: i32,
2328 required: bool,
2329 #[serde(default)]
2330 case_pass: Option<usize>,
2331 #[serde(default)]
2332 case_fail: Option<usize>,
2333 #[serde(default)]
2334 assert_pass: Option<usize>,
2335 #[serde(default)]
2336 assert_fail: Option<usize>,
2337 #[serde(default)]
2338 check_pass: Option<usize>,
2339 #[serde(default)]
2340 check_fail: Option<usize>,
2341 #[serde(default)]
2342 check_validation_fail: Option<usize>,
2343 #[serde(default)]
2344 check_runner_fail: Option<usize>,
2345}
2346
2347#[derive(Debug, Deserialize)]
2348struct ParsedCubeFacts {
2349 cases: Vec<ParsedCubeCaseRow>,
2350 actions: Vec<ParsedCubeActionRow>,
2351 asserts: Vec<ParsedCubeAssertRow>,
2352 #[serde(default)]
2353 cmake_checks: Vec<ParsedCubeCmakeCheckRow>,
2354 telemetry: Vec<ParsedCubeTelemetryRow>,
2355}
2356
2357#[derive(Debug, Deserialize)]
2358struct ParsedCubeCmakeCheckRow {
2359 row_ix: usize,
2360 check_id: String,
2361 check_kind: String,
2362 status: CmakeCheckStatus,
2363 #[serde(default)]
2364 fail_class: Option<CmakeCheckFailClass>,
2365 #[serde(default)]
2366 msg: Option<String>,
2367 #[serde(default)]
2368 checked_targets: Vec<CmakeTargetRef>,
2369 #[serde(default)]
2370 missing_artifacts: Vec<CmakeMissingArtifact>,
2371}
2372
2373fn extract_checked_targets(check: &CmakeCheckRecord) -> AnalyticsResult<Vec<CmakeTargetRef>> {
2374 let Some(ok) = check.ok.as_ref() else {
2375 return Ok(Vec::new());
2376 };
2377 let Some(targets) = ok.get("checked_targets") else {
2378 return Ok(Vec::new());
2379 };
2380 serde_json::from_value(targets.clone()).map_err(|error| {
2381 AnalyticsError::new(
2382 AnalyticsErrorCode::InvalidReportShape,
2383 format!(
2384 "cmake check `{}` had invalid checked_targets payload: {error}",
2385 check.check_id
2386 ),
2387 )
2388 })
2389}
2390
2391fn validate_cmake_report_shape(
2392 records: &[CmakeModelReportRecord],
2393) -> AnalyticsResult<(CmakeModelReportHeader, CmakeSummaryRecord)> {
2394 let header_count = records
2395 .iter()
2396 .filter(|record| matches!(record, CmakeModelReportRecord::Header(_)))
2397 .count();
2398 let summary_count = records
2399 .iter()
2400 .filter(|record| matches!(record, CmakeModelReportRecord::Summary(_)))
2401 .count();
2402 if header_count != 1 {
2403 return Err(AnalyticsError::new(
2404 AnalyticsErrorCode::InvalidReportShape,
2405 format!("cmake report must contain exactly one header record, found {header_count}"),
2406 ));
2407 }
2408 if summary_count != 1 {
2409 return Err(AnalyticsError::new(
2410 AnalyticsErrorCode::InvalidReportShape,
2411 format!("cmake report must contain exactly one final summary record, found {summary_count}"),
2412 ));
2413 }
2414 let Some(CmakeModelReportRecord::Header(header)) = records.first() else {
2415 return Err(AnalyticsError::new(
2416 AnalyticsErrorCode::InvalidReportShape,
2417 "cmake report must begin with header record",
2418 ));
2419 };
2420 let Some(CmakeModelReportRecord::Summary(summary)) = records.last() else {
2421 return Err(AnalyticsError::new(
2422 AnalyticsErrorCode::InvalidReportShape,
2423 "cmake report missing final summary record",
2424 ));
2425 };
2426 let expected_status = if summary.check_fail == 0
2427 && summary.check_validation_fail == 0
2428 && summary.check_runner_fail == 0
2429 {
2430 "pass"
2431 } else {
2432 "fail"
2433 };
2434 if summary.status != expected_status {
2435 return Err(AnalyticsError::new(
2436 AnalyticsErrorCode::InvalidReportShape,
2437 format!(
2438 "cmake summary status `{}` did not match summary counters",
2439 summary.status
2440 ),
2441 ));
2442 }
2443 Ok((header.clone(), summary.clone()))
2444}
2445
2446#[derive(Debug, Deserialize)]
2447struct ParsedCubeCaseRow {
2448 row_ix: usize,
2449 case_id: String,
2450 item_id: String,
2451 test_name: String,
2452 status: Status,
2453 assert_pass: usize,
2454 assert_fail: usize,
2455 unhandled_action_fail: usize,
2456}
2457
2458#[derive(Debug, Deserialize)]
2459struct ParsedCubeActionRow {
2460 row_ix: usize,
2461 case_id: String,
2462 item_id: String,
2463 test_name: String,
2464 action_ix: usize,
2465 action: String,
2466 status: ActionStatus,
2467 fail_kind: Option<String>,
2468 fail_code: Option<String>,
2469 exit: Option<i64>,
2470 out_len: Option<u64>,
2471 err_len: Option<u64>,
2472 body_len: Option<u64>,
2473 bytes_len: Option<u64>,
2474}
2475
2476#[derive(Debug, Deserialize)]
2477struct ParsedCubeAssertRow {
2478 row_ix: usize,
2479 case_id: String,
2480 item_id: String,
2481 test_name: String,
2482 assert_ix: usize,
2483 status: Status,
2484 msg: String,
2485}
2486
2487#[derive(Debug, Deserialize)]
2488struct ParsedCubeTelemetryRow {
2489 row_ix: usize,
2490 case_id: String,
2491 item_id: String,
2492 test_name: String,
2493 action_ix: Option<usize>,
2494 scope: TelemetryScope,
2495 name: String,
2496 kind: String,
2497 unit: Option<String>,
2498 value: Option<Number>,
2499 values: Option<Vec<Number>>,
2500 tag_value: Option<String>,
2501 canonical: bool,
2502}
2503
2504fn parse_cube_telemetry_kind(value: &str) -> AnalyticsResult<&'static str> {
2505 match value {
2506 "metric" => Ok("metric"),
2507 "vector" => Ok("vector"),
2508 "tag" => Ok("tag"),
2509 _ => Err(AnalyticsError::new(
2510 AnalyticsErrorCode::InvalidCubeShape,
2511 format!("unsupported telemetry kind `{value}` in cube artifact"),
2512 )),
2513 }
2514}
2515
2516fn static_cube_order_field(value: &str) -> AnalyticsResult<&'static str> {
2517 match value {
2518 "report_case_order_then_action_ix" => Ok("report_case_order_then_action_ix"),
2519 "report_case_order_then_assert_ix" => Ok("report_case_order_then_assert_ix"),
2520 "report_case_order" => Ok("report_case_order"),
2521 "report_check_order" => Ok("report_check_order"),
2522 "utf8_lexicographic" => Ok("utf8_lexicographic"),
2523 "report_case_order_then_action_ix_then_record_order" => {
2524 Ok("report_case_order_then_action_ix_then_record_order")
2525 }
2526 _ => Err(AnalyticsError::new(
2527 AnalyticsErrorCode::InvalidCubeShape,
2528 format!("unsupported cube order field `{value}`"),
2529 )),
2530 }
2531}