@crvouga/atlas 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +7 -1
  2. package/dist/reports/src/explorer-types.d.ts +19 -0
  3. package/dist/reports/src/latency.d.ts +204 -0
  4. package/dist/reports/src/latency.js +219 -0
  5. package/dist/reports/src/reports-explorer-route.d.ts +19 -6
  6. package/dist/reports/src/reports-route.d.ts +19 -6
  7. package/dist/reports/src/test-catalog.d.ts +3 -1
  8. package/dist/reports/src/test-catalog.js +4 -2
  9. package/dist/schema/src/api.d.ts +151 -0
  10. package/dist/schema/src/api.js +4 -3
  11. package/dist/schema/src/layer.d.ts +90 -0
  12. package/dist/schema/src/layer.js +20 -1
  13. package/docs/extending.md +1 -1
  14. package/docs/reference/reports.md +347 -0
  15. package/docs/reference/schema.md +28 -0
  16. package/docs/rich-evidence.md +43 -0
  17. package/docs/ui.md +10 -1
  18. package/llms-full.txt +54 -2
  19. package/package.json +1 -1
  20. package/packages/schema/schemas/check-detail.schema.json +56 -1
  21. package/packages/schema/schemas/effective-delta.schema.json +60 -2
  22. package/packages/schema/schemas/effective-evidence.schema.json +60 -2
  23. package/packages/schema/schemas/layer-rows.schema.json +60 -2
  24. package/packages/schema/schemas/layer.schema.json +112 -2
  25. package/packages/schema/schemas/publish-checks.schema.json +56 -1
  26. package/packages/schema/schemas/test-catalog.schema.json +85 -0
  27. package/packages/schema/schemas/test-history.schema.json +170 -0
  28. package/packages/server/openapi.json +547 -8
  29. package/packages/visualizer/src/evidence/check-detail.tsx +9 -0
  30. package/packages/visualizer/src/explorer/ProductLatency.tsx +214 -0
  31. package/packages/visualizer/src/explorer/TestDetail.tsx +9 -2
@@ -2,6 +2,7 @@ import { recordingPaths } from '@crvouga/atlas/reports/evidence-types';
2
2
  import { checkHistory, type CheckSlot, type HistoryEntry } from '@crvouga/atlas/reports/evidence-view';
3
3
  import { flattenFrames } from '@crvouga/atlas/reports/frames';
4
4
  import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
5
+ import { boundaryOf, isProductLatency } from '@crvouga/atlas/reports/latency';
5
6
  import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
6
7
  import type { ReportStep, RunReport } from '@crvouga/atlas/reports/report-types';
7
8
  import { Accordion, Alert, Anchor, Box, Button, Code, Flex, Group, List, Stack, Table, Text, Title } from '@mantine/core';
@@ -435,6 +436,14 @@ function RecordedCheckDetail({ slot, layerId, select, reads, written, pinned, pi
435
436
  against an allowance of {measurement.limit} {measurement.unit}
436
437
  </Text>
437
438
  )}
439
+ {isProductLatency(measurement) && (
440
+ <Text span inherit c="dimmed">
441
+ {' '}
442
+ · product latency, {boundaryOf(measurement).label}
443
+ {boundaryOf(measurement).state === 'partial' && ` (partial: not ${boundaryOf(measurement).intended})`}
444
+ {measurement.outcome !== undefined && measurement.outcome !== 'observed' && ` · ${measurement.outcome}: waited at least this long`}
445
+ </Text>
446
+ )}
438
447
  </Text>
439
448
  );
440
449
  })
@@ -0,0 +1,214 @@
1
+ /**
2
+ * @module Product latency view
3
+ *
4
+ * How quickly the product responded to a user, apart from how long the test took. It reads only the measurements a
5
+ * producer named as product latency (see `@crvouga/atlas/reports/latency`) and shows, for each, its boundaries, unit,
6
+ * source, group (device, build or day), sample count, budget and breaches, p50 and p95 when enough observations
7
+ * support them, and a comparison with a chosen baseline that names every difference in instrumentation or
8
+ * conditions. What the evidence cannot support is withheld with the reason. A test with no named measurement says its
9
+ * product latency is unknown; its execution time is never shown here.
10
+ */
11
+ import {
12
+ compareLatency, formatLatency, latencyReview, LATENCY_GROUPINGS, type LatencyChange, type LatencyGrouping, type LatencyReview, type LatencySummary
13
+ } from '@crvouga/atlas/reports/latency';
14
+ import type { TestResultEntry } from '@crvouga/atlas/reports/test-catalog';
15
+ import { Badge, Box, Code, Flex, List, NativeSelect, Stack, Table, Text, Title } from '@mantine/core';
16
+ import { useMemo, useState } from 'react';
17
+
18
+ import { plural } from '../evidence/format';
19
+
20
+ const GROUPING_LABEL: Record<LatencyGrouping, string> = { none: 'Not grouped', device: 'Device', build: 'Build', window: 'Day recorded' };
21
+
22
+ const sign = (value: number) => (value > 0 ? '+' : value < 0 ? '−' : '');
23
+
24
+ /** A change as words: `+100 ms (+25%) in p50`. */
25
+ function changeText(change: LatencyChange, unit: string): string {
26
+ const delta = change.p50 ?? change.p95 ?? change.last;
27
+ if (!change.comparable || !delta) return 'Not compared';
28
+ const relative = delta.relative === null ? 'baseline was 0' : `${sign(delta.relative)}${Math.abs(Math.round(delta.relative * 1000) / 10)}%`;
29
+ const basis = change.basis === 'last' ? 'in the newest observation, not a percentile' : `in ${change.basis}`;
30
+ return `${sign(delta.absolute)}${formatLatency(Math.abs(delta.absolute), unit)} (${relative}) ${basis}`;
31
+ }
32
+
33
+ function Figure({ value, unit, summary }: { value: number | undefined; unit: string; summary: LatencySummary }) {
34
+ return value === undefined ? (
35
+ <Text span inherit c="dimmed">
36
+ Withheld{summary.claim ? '' : ' (no boundaries)'}
37
+ </Text>
38
+ ) : (
39
+ <>
40
+ {summary.lowerBound ? '≥ ' : ''}
41
+ {formatLatency(value, unit)}
42
+ </>
43
+ );
44
+ }
45
+
46
+ function MetricTable({ summaries, baseline }: { summaries: readonly LatencySummary[]; baseline: LatencySummary | undefined }) {
47
+ const unit = summaries[0]!.metric.unit;
48
+ const changes = new Map(summaries.map((summary) => [summary.group, baseline && summary !== baseline ? compareLatency(baseline, summary) : undefined]));
49
+ return (
50
+ <Table.ScrollContainer minWidth={560}>
51
+ <Table>
52
+ <Table.Thead>
53
+ <Table.Tr>
54
+ <Table.Th scope="col">Group</Table.Th>
55
+ <Table.Th scope="col">Observations</Table.Th>
56
+ <Table.Th scope="col">p50</Table.Th>
57
+ <Table.Th scope="col">p95</Table.Th>
58
+ <Table.Th scope="col">Budget</Table.Th>
59
+ <Table.Th scope="col">Over budget</Table.Th>
60
+ {baseline && <Table.Th scope="col">Against baseline</Table.Th>}
61
+ </Table.Tr>
62
+ </Table.Thead>
63
+ <Table.Tbody>
64
+ {summaries.map((summary) => {
65
+ const change = changes.get(summary.group);
66
+ return (
67
+ <Table.Tr key={summary.group}>
68
+ <Table.Th scope="row">{summary.group}</Table.Th>
69
+ <Table.Td>
70
+ {summary.samples}
71
+ {summary.timeouts + summary.failures > 0 && (
72
+ <Text span inherit c="dimmed">
73
+ {' '}
74
+ ({summary.timeouts + summary.failures} did not complete)
75
+ </Text>
76
+ )}
77
+ </Table.Td>
78
+ <Table.Td>
79
+ <Figure value={summary.p50} unit={unit} summary={summary} />
80
+ </Table.Td>
81
+ <Table.Td>
82
+ <Figure value={summary.p95} unit={unit} summary={summary} />
83
+ </Table.Td>
84
+ <Table.Td>{summary.budget === undefined ? <Text span inherit c="dimmed">None set</Text> : formatLatency(summary.budget, unit)}</Table.Td>
85
+ <Table.Td>
86
+ {summary.budget === undefined ? (
87
+ '—'
88
+ ) : summary.breaches > 0 ? (
89
+ <Badge color="red" variant="light">
90
+ {summary.breaches} of {summary.samples} over
91
+ </Badge>
92
+ ) : (
93
+ `0 of ${summary.samples}`
94
+ )}
95
+ </Table.Td>
96
+ {baseline && <Table.Td>{summary === baseline ? 'Baseline' : change ? changeText(change, unit) : '—'}</Table.Td>}
97
+ </Table.Tr>
98
+ );
99
+ })}
100
+ </Table.Tbody>
101
+ </Table>
102
+ </Table.ScrollContainer>
103
+ );
104
+ }
105
+
106
+ /** What a reader must not assume, per group and per comparison, as one list. */
107
+ function Notes({ summaries, baseline }: { summaries: readonly LatencySummary[]; baseline: LatencySummary | undefined }) {
108
+ const lines: string[] = [];
109
+ for (const summary of summaries) {
110
+ const name = summaries.length > 1 ? `${summary.group}: ` : '';
111
+ for (const line of [...summary.gaps, ...summary.withheld]) lines.push(`${name}${line}`);
112
+ if (baseline && summary !== baseline) {
113
+ const change = compareLatency(baseline, summary);
114
+ for (const reason of change.reasons) lines.push(`${summary.group} against ${baseline.group}: ${reason}`);
115
+ for (const disclosure of change.disclosures) lines.push(`${summary.group} against ${baseline.group}: ${disclosure}`);
116
+ }
117
+ }
118
+ if (!lines.length) return null;
119
+ return (
120
+ <List aria-label="What these figures do not show" size="sm" c="dimmed">
121
+ {lines.map((line, index) => (
122
+ <List.Item key={index}>{line}</List.Item>
123
+ ))}
124
+ </List>
125
+ );
126
+ }
127
+
128
+ function Metric({ metric, groups, baselineGroup }: { metric: LatencyReview['metrics'][number]['metric']; groups: readonly LatencySummary[]; baselineGroup: string | undefined }) {
129
+ const baseline = groups.length > 1 ? (groups.find((summary) => summary.group === baselineGroup) ?? groups[1]) : undefined;
130
+ const first = groups[0]!;
131
+ const source = [...new Set(groups.map((summary) => summary.metric.boundary.label))];
132
+ return (
133
+ <Stack gap="xs" aria-label={metric.name} role="group">
134
+ <Flex gap="xs" align="center" wrap="wrap">
135
+ <Text span inherit fw={600}>
136
+ {metric.name}
137
+ </Text>
138
+ <Text span inherit c="dimmed">
139
+ {source.join(', ')} · {metric.unit}
140
+ </Text>
141
+ {metric.boundary.state === 'partial' && <Badge color="orange" variant="light">Partial interval</Badge>}
142
+ {metric.boundary.state === 'undeclared' && <Badge color="gray" variant="light">Boundaries not declared</Badge>}
143
+ {first.lowerBound && <Badge color="orange" variant="light">Includes timeouts or failures</Badge>}
144
+ </Flex>
145
+ <MetricTable summaries={groups} baseline={baseline} />
146
+ <Notes summaries={groups} baseline={baseline} />
147
+ </Stack>
148
+ );
149
+ }
150
+
151
+ /**
152
+ * The product latency of one test, from its recorded results (newest first, as the history lists them). Grouping and
153
+ * the baseline are the viewer's own choices and are not saved. With nothing recorded it says product latency is
154
+ * unknown and how a producer records it.
155
+ */
156
+ export function ProductLatency({ entries }: { entries: readonly TestResultEntry[] }) {
157
+ const [by, setBy] = useState<LatencyGrouping>('none');
158
+ const [baseline, setBaseline] = useState<string | undefined>(undefined);
159
+ const review = useMemo(() => latencyReview(entries, by), [entries, by]);
160
+ const groups = [...new Set(review.metrics.flatMap((item) => item.groups.map((summary) => summary.group)))];
161
+ return (
162
+ <Box aria-label="Product latency" component="section" mb="lg">
163
+ <Title order={3} mb="sm" c="dimmed">
164
+ Product latency
165
+ </Title>
166
+ <Text component="div" c="dimmed" mb="sm">
167
+ How quickly the product responded to a user, between two events the producer named. It is not the test's duration, which includes setup,
168
+ waits, retries and any demo pacing.
169
+ </Text>
170
+ {review.metrics.length === 0 ? (
171
+ <Text component="div" role="status">
172
+ No product latency was recorded for this test, so it is unknown, not fast. A producer records one as a measurement with{' '}
173
+ <Code>kind: "product-latency"</Code>, a <Code>from</Code> and a <Code>to</Code>, and optionally a <Code>limit</Code> as the budget.
174
+ </Text>
175
+ ) : (
176
+ <Stack gap="md">
177
+ <Flex gap="md" wrap="wrap" align="flex-end">
178
+ <NativeSelect
179
+ label="Group latency by"
180
+ value={by}
181
+ onChange={(event) => {
182
+ setBy(event.target.value as LatencyGrouping);
183
+ setBaseline(undefined);
184
+ }}
185
+ >
186
+ {LATENCY_GROUPINGS.map((option) => (
187
+ <option key={option} value={option}>
188
+ {GROUPING_LABEL[option]}
189
+ </option>
190
+ ))}
191
+ </NativeSelect>
192
+ {groups.length > 1 && (
193
+ <NativeSelect label="Baseline to compare against" value={baseline ?? ''} onChange={(event) => setBaseline(event.target.value || undefined)}>
194
+ <option value="">Second newest group</option>
195
+ {groups.map((group) => (
196
+ <option key={group} value={group}>
197
+ {group}
198
+ </option>
199
+ ))}
200
+ </NativeSelect>
201
+ )}
202
+ </Flex>
203
+ {review.metrics.map(({ metric, groups: summaries }) => (
204
+ <Metric key={metric.key} metric={metric} groups={summaries} baselineGroup={baseline} />
205
+ ))}
206
+ <Text component="div" c="dimmed">
207
+ {plural(review.measured, 'recorded result carries', 'recorded results carry')} product latency
208
+ {review.unmeasured > 0 ? `; ${plural(review.unmeasured, 'result records', 'results record')} none, which is unknown, not fast` : ''}.
209
+ </Text>
210
+ </Stack>
211
+ )}
212
+ </Box>
213
+ );
214
+ }
@@ -21,6 +21,7 @@ import { outcomeHeadline, selectedResult } from '../evidence/outcome';
21
21
  import { useTestHistory } from './catalog';
22
22
  import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
23
23
  import { Badges, runName } from './parts';
24
+ import { ProductLatency } from './ProductLatency';
24
25
  import { usePinnedResult, type PinnedResult } from './pinned';
25
26
  const RecordingPage = lazy(() => import('../evidence/recording-page').then((module) => ({ default: module.RecordingPage })));
26
27
  /** Results the history table shows before "Show more". */
@@ -310,6 +311,10 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
310
311
  <Title order={3} mb="sm" c="dimmed">
311
312
  How it has gone
312
313
  </Title>
314
+ <Text component="div" c="dimmed" mb="sm">
315
+ Durations here are test execution time: setup, waits, retries and any demo pacing included. They do not say how quickly the product
316
+ responded; see Product latency.
317
+ </Text>
313
318
  <SimpleGrid component="dl" cols={{ base: 2, sm: 3 }}>
314
319
  <Stack gap="sm">
315
320
  <Box component="dt">Pass rate</Box>
@@ -394,12 +399,14 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
394
399
  )}
395
400
  {!d && (
396
401
  <Text component="div" c="dimmed">
397
- No duration recorded. Producers can send <Code>durationMs</Code> with each check.
402
+ No test duration recorded. Producers can send <Code>durationMs</Code> with each check.
398
403
  </Text>
399
404
  )}
400
405
  {entries.length > 1 && <Trend entries={entries} />}
401
406
  </Box>
402
407
 
408
+ <ProductLatency entries={entries} />
409
+
403
410
  {(test.tags.length > 0 || Object.keys(test.labels).length > 0) && (
404
411
  <Box aria-label="Tags and labels" component="section" mb="lg">
405
412
  <Title order={3} mb="sm" c="dimmed">
@@ -577,7 +584,7 @@ function Trend({ entries }: { entries: readonly TestResultEntry[] }) {
577
584
  {timed.length > 1 && (
578
585
  <Box component="figure" m={0}>
579
586
  <Box component="figcaption">
580
- Duration over the last {plural(timed.length, 'result', 'results')} · max {formatMs(max)}
587
+ Test duration over the last {plural(timed.length, 'result', 'results')} · max {formatMs(max)}
581
588
  </Box>
582
589
  <svg
583
590
  viewBox={`0 0 ${width} ${height}`}