Skip to content

Commit 2af3940

Browse files
feat: add trend visualization with sparklines to perf report (#9939)
## Summary Add historical trend visualization (ASCII sparklines + directional arrows) to the performance PR report, showing how each metric has moved over recent commits on main. ## Changes - **What**: New `sparkline()`, `trendDirection()`, `trendArrow()` functions in `perf-stats.ts`. New collapsible "Trend" section in the perf report showing per-metric sparklines, direction indicators, and latest values. CI workflow updated to download historical data from the `perf-data` orphan branch and switched to `setup-frontend` action with `pnpm exec tsx`. ## Review Focus - The trend section only renders when ≥3 historical data points exist (gracefully absent otherwise) - `trendDirection()` uses a split-half mean comparison with ±10% threshold — review whether this sensitivity is appropriate - The `git archive` step in `pr-perf-report.yaml` is idempotent and fails silently if no perf-history data exists yet on the perf-data branch ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9939-feat-add-trend-visualization-with-sparklines-to-perf-report-3246d73d36508125a6fcc39612f850fe) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com>
1 parent 48ae701 commit 2af3940

File tree

4 files changed

+188
-2
lines changed

4 files changed

+188
-2
lines changed

.github/workflows/pr-report.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,19 @@ jobs:
173173
path: temp/perf-baseline/
174174
if_no_artifact_found: warn
175175

176+
- name: Download perf history from perf-data branch
177+
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
178+
continue-on-error: true
179+
run: |
180+
if git ls-remote --exit-code origin perf-data >/dev/null 2>&1; then
181+
git fetch origin perf-data --depth=1
182+
mkdir -p temp/perf-history
183+
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -10); do
184+
git show "origin/perf-data:${file}" > "temp/perf-history/$(basename "$file")" 2>/dev/null || true
185+
done
186+
echo "Loaded $(ls temp/perf-history/*.json 2>/dev/null | wc -l) historical baselines"
187+
fi
188+
176189
- name: Generate unified report
177190
if: steps.pr-meta.outputs.skip != 'true'
178191
run: >

scripts/perf-report.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import {
77
computeStats,
88
formatSignificance,
99
isNoteworthy,
10+
sparkline,
11+
trendArrow,
12+
trendDirection,
1013
zScore
1114
} from './perf-stats'
1215

@@ -73,8 +76,11 @@ function groupByName(
7376
function loadHistoricalReports(): PerfReport[] {
7477
if (!existsSync(HISTORY_DIR)) return []
7578
const reports: PerfReport[] = []
76-
for (const dir of readdirSync(HISTORY_DIR)) {
77-
const filePath = join(HISTORY_DIR, dir, 'perf-metrics.json')
79+
for (const entry of readdirSync(HISTORY_DIR)) {
80+
const entryPath = join(HISTORY_DIR, entry)
81+
const filePath = entry.endsWith('.json')
82+
? entryPath
83+
: join(entryPath, 'perf-metrics.json')
7884
if (!existsSync(filePath)) continue
7985
try {
8086
reports.push(JSON.parse(readFileSync(filePath, 'utf-8')) as PerfReport)
@@ -102,6 +108,27 @@ function getHistoricalStats(
102108
return computeStats(values)
103109
}
104110

111+
function getHistoricalTimeSeries(
112+
reports: PerfReport[],
113+
testName: string,
114+
metric: MetricKey
115+
): number[] {
116+
const sorted = [...reports].sort(
117+
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
118+
)
119+
const values: number[] = []
120+
for (const r of sorted) {
121+
const group = groupByName(r.measurements)
122+
const samples = group.get(testName)
123+
if (samples) {
124+
values.push(
125+
samples.reduce((sum, s) => sum + s[metric], 0) / samples.length
126+
)
127+
}
128+
}
129+
return values
130+
}
131+
105132
function computeCV(stats: MetricStats): number {
106133
return stats.mean > 0 ? (stats.stddev / stats.mean) * 100 : 0
107134
}
@@ -233,6 +260,34 @@ function renderFullReport(
233260
}
234261
lines.push('', '</details>')
235262

263+
const trendRows: string[] = []
264+
for (const [testName] of prGroups) {
265+
for (const { key, label, unit } of REPORTED_METRICS) {
266+
const series = getHistoricalTimeSeries(historical, testName, key)
267+
if (series.length < 3) continue
268+
const dir = trendDirection(series)
269+
const arrow = trendArrow(dir)
270+
const spark = sparkline(series)
271+
const last = series[series.length - 1]
272+
trendRows.push(
273+
`| ${testName}: ${label} | ${spark} | ${arrow} | ${formatValue(last, unit)} |`
274+
)
275+
}
276+
}
277+
278+
if (trendRows.length > 0) {
279+
lines.push(
280+
'',
281+
`<details><summary>Trend (last ${historical.length} commits on main)</summary>`,
282+
'',
283+
'| Metric | Trend | Dir | Latest |',
284+
'|--------|-------|-----|--------|',
285+
...trendRows,
286+
'',
287+
'</details>'
288+
)
289+
}
290+
236291
return lines
237292
}
238293

scripts/perf-stats.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import {
55
computeStats,
66
formatSignificance,
77
isNoteworthy,
8+
sparkline,
9+
trendArrow,
10+
trendDirection,
811
zScore
912
} from './perf-stats'
1013

@@ -131,3 +134,68 @@ describe('isNoteworthy', () => {
131134
expect(isNoteworthy('noisy')).toBe(false)
132135
})
133136
})
137+
138+
describe('sparkline', () => {
139+
it('returns empty string for no values', () => {
140+
expect(sparkline([])).toBe('')
141+
})
142+
143+
it('returns mid-height for single value', () => {
144+
expect(sparkline([50])).toBe('▄')
145+
})
146+
147+
it('renders ascending values low to high', () => {
148+
const result = sparkline([0, 25, 50, 75, 100])
149+
expect(result).toBe('▁▃▅▆█')
150+
})
151+
152+
it('renders identical values as flat line', () => {
153+
const result = sparkline([10, 10, 10])
154+
expect(result).toBe('▄▄▄')
155+
})
156+
157+
it('renders descending values high to low', () => {
158+
const result = sparkline([100, 50, 0])
159+
expect(result).toBe('█▅▁')
160+
})
161+
})
162+
163+
describe('trendDirection', () => {
164+
it('returns stable for fewer than 3 values', () => {
165+
expect(trendDirection([])).toBe('stable')
166+
expect(trendDirection([1])).toBe('stable')
167+
expect(trendDirection([1, 2])).toBe('stable')
168+
})
169+
170+
it('detects rising trend', () => {
171+
expect(trendDirection([10, 10, 10, 20, 20, 20])).toBe('rising')
172+
})
173+
174+
it('detects falling trend', () => {
175+
expect(trendDirection([20, 20, 20, 10, 10, 10])).toBe('falling')
176+
})
177+
178+
it('returns stable for flat data', () => {
179+
expect(trendDirection([100, 100, 100, 100])).toBe('stable')
180+
})
181+
182+
it('returns stable for small fluctuations within 10%', () => {
183+
expect(trendDirection([100, 100, 100, 105, 105, 105])).toBe('stable')
184+
})
185+
186+
it('detects rising when baseline is zero but current is non-zero', () => {
187+
expect(trendDirection([0, 0, 0, 5, 5, 5])).toBe('rising')
188+
})
189+
190+
it('returns stable when both halves are zero', () => {
191+
expect(trendDirection([0, 0, 0, 0, 0, 0])).toBe('stable')
192+
})
193+
})
194+
195+
describe('trendArrow', () => {
196+
it('returns correct emoji for each direction', () => {
197+
expect(trendArrow('rising')).toBe('📈')
198+
expect(trendArrow('falling')).toBe('📉')
199+
expect(trendArrow('stable')).toBe('➡️')
200+
})
201+
})

scripts/perf-stats.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,53 @@ export function formatSignificance(
6161
export function isNoteworthy(sig: Significance): boolean {
6262
return sig === 'regression'
6363
}
64+
65+
const SPARK_CHARS = '▁▂▃▄▅▆▇█'
66+
67+
export function sparkline(values: number[]): string {
68+
if (values.length === 0) return ''
69+
if (values.length === 1) return SPARK_CHARS[3]
70+
71+
const min = Math.min(...values)
72+
const max = Math.max(...values)
73+
const range = max - min
74+
75+
return values
76+
.map((v) => {
77+
if (range === 0) return SPARK_CHARS[3]
78+
const idx = Math.round(((v - min) / range) * (SPARK_CHARS.length - 1))
79+
return SPARK_CHARS[idx]
80+
})
81+
.join('')
82+
}
83+
84+
export type TrendDirection = 'rising' | 'falling' | 'stable'
85+
86+
export function trendDirection(values: number[]): TrendDirection {
87+
if (values.length < 3) return 'stable'
88+
89+
const half = Math.floor(values.length / 2)
90+
const firstHalf = values.slice(0, half)
91+
const secondHalf = values.slice(-half)
92+
93+
const firstMean = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length
94+
const secondMean = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length
95+
96+
if (firstMean === 0) return secondMean > 0 ? 'rising' : 'stable'
97+
const changePct = ((secondMean - firstMean) / firstMean) * 100
98+
99+
if (changePct > 10) return 'rising'
100+
if (changePct < -10) return 'falling'
101+
return 'stable'
102+
}
103+
104+
export function trendArrow(dir: TrendDirection): string {
105+
switch (dir) {
106+
case 'rising':
107+
return '📈'
108+
case 'falling':
109+
return '📉'
110+
case 'stable':
111+
return '➡️'
112+
}
113+
}

0 commit comments

Comments
 (0)