perf(tooling): report which animations run during a streaming capture

Compositing shows up in a trace as Layerize, Commit and PrePaint with no
indication of what caused it. The streaming profiler now snapshots
document.getAnimations() mid-capture and reports the running animations by
keyframe and target, which names the elements keeping the compositor busy
instead of leaving the trace to be guessed at.
This commit is contained in:
Bohdan Triapitsyn
2026-08-03 16:43:19 +03:00
parent ea9bb52fe7
commit 262b1eb18a
+54
View File
@@ -176,6 +176,43 @@ const countRenderedMessages = async (client) => {
}
}
/**
* Snapshots the animations the page is running right now.
*
* Compositing work shows up in a trace as `Layerize`/`Commit`/`PrePaint` with
* no indication of what caused it. `document.getAnimations()` names the
* culprits directly, which turns "the compositor is busy" into a specific list
* of elements and keyframes.
*/
const snapshotAnimations = async (client) => {
const raw = await evaluateValue(client, `JSON.stringify((() => {
if (typeof document.getAnimations !== "function") return []
const describe = (animation) => {
const target = animation.effect && animation.effect.target
const identity = target
? \`\${target.tagName.toLowerCase()}\${target.className && typeof target.className === "string" ? "." + target.className.trim().split(/\\s+/).slice(0, 4).join(".") : ""}\`
: "(no target)"
const keyframe = animation.animationName
|| (animation.effect && animation.effect.getKeyframes && animation.effect.getKeyframes().length ? "transition/keyframes" : "unknown")
return \`\${animation.playState} \${keyframe} on \${identity}\`
}
const counts = new Map()
for (const animation of document.getAnimations()) {
const key = describe(animation)
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return [...counts.entries()]
.sort((left, right) => right[1] - left[1])
.slice(0, 20)
.map(([description, count]) => ({ description, count }))
})())`)
try {
return JSON.parse(raw ?? "[]")
} catch {
return []
}
}
const REPORTED_METRICS = [
{ key: "longTaskCount", label: "Long tasks (>50ms)", unit: "", lowerIsBetter: true },
{ key: "longestTaskMs", label: "Longest task", unit: "ms", lowerIsBetter: true },
@@ -224,6 +261,15 @@ const printReport = (summary, baseline) => {
console.log(` ${String(entry.selfMs).padStart(9)} ms ${String(entry.percentOfBusy).padStart(5)}% ${entry.function}`)
}
if (summary.runningAnimations?.length) {
console.log("\nAnimations running mid-stream:")
for (const entry of summary.runningAnimations.slice(0, 12)) {
console.log(` ${String(entry.count).padStart(4)}x ${entry.description}`)
}
} else {
console.log("\nAnimations running mid-stream: none")
}
console.log("\nWhere recorded time went (timeline trace):")
for (const entry of summary.traceBreakdown?.slice(0, 14) ?? []) {
console.log(` ${String(entry.totalMs).padStart(9)} ms ${String(entry.count).padStart(6)}x max ${String(entry.maxMs).padStart(7)} ms ${entry.name}`)
@@ -362,6 +408,7 @@ const main = async () => {
const samples = []
let dispatchError = null
let animationSnapshot = null
let becameIdle = false
dispatch.catch((error) => { dispatchError = error })
@@ -379,6 +426,12 @@ const main = async () => {
})
if (dispatchError) break
// One mid-stream snapshot is enough to name a continuously running
// animation, and avoids polling overhead inside the measured window.
if (animationSnapshot === null && Date.now() - startedAt > 15_000) {
animationSnapshot = await snapshotAnimations(client)
}
// `session status` is the authoritative activity source; polling it
// avoids inferring completion from render or network quiet periods,
// which a slow provider would misreport as a finished response.
@@ -493,6 +546,7 @@ const main = async () => {
heapGrowthMbPerSecond: growthPerSecond(samples, "jsHeapUsedMb"),
},
frameLiveness,
runningAnimations: animationSnapshot ?? [],
cpuProfile: summarizeCpuProfile(profile),
traceBreakdown,
streamPerformance,