Files
PaperClipAI/ui/src/lib/issue-detail-performance.ts
T
2494a2a0fe perf: add repeatable issue-detail baseline rig (#10409)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The issue detail page is a core operator surface where perceived
latency directly affects task navigation
> - Performance work needs repeatable evidence so later optimizations
can be compared against the same scenarios
> - The page did not expose stable user-timing marks for its header or
first useful content
> - There was also no isolated seeded browser rig that measured warm
navigation, cold deep links, waterfalls, or server time
> - This pull request adds the instrumentation and a one-command
Playwright baseline harness
> - The benefit is that issue-page performance changes can be validated
with reproducible median measurements instead of anecdotes

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting: `ui/`, `server/`, and browser performance tooling.

**Problem or motivation**

The issue detail page performs a large client bootstrap and request
fan-out, but the repository lacks stable user-timing boundaries and a
repeatable benchmark. That makes performance changes difficult to
compare and allows regressions to be judged from anecdotes instead of
consistent evidence.

**Proposed solution**

Add stable header/content paint measures, development/QA-only lifecycle
vital reporting, aggregate server timing for the issue endpoint, and a
seeded Playwright command that runs warm/cold scenarios under throttled
and unthrottled profiles with N≥5 median reporting.

**Alternatives considered**

Ad hoc DevTools recordings were rejected because they are not repeatable
or reviewable. Production telemetry was rejected because this baseline
should not change production data collection. A unit-only harness was
rejected because it cannot capture browser bootstrap, rendering, and
network waterfall costs.

**Roadmap alignment**

The roadmap calls for agent performance to be measurable over time. This
change applies that evidence-first principle to a core operator page and
does not duplicate a listed roadmap deliverable.

**Additional context**

The generated report includes warm and cold medians, TTFB/FCP/LCP where
applicable, request and byte totals before first useful content,
JavaScript bytes, and issue endpoint server timing.

## What Changed

- Added `issue-detail:navigate→header-paint` and
`issue-detail:navigate→content-paint` user-timing measures to the issue
detail page.
- Added development/QA-only TTFB, LCP, and INP console reporting without
production telemetry delivery.
- Added `Server-Timing` for `GET /api/issues/:id`.
- Added `pnpm exec playwright test --config
tests/perf/issue-detail/playwright.config.ts`, which seeds an isolated
instance and runs N≥5 warm/cold samples under unthrottled and Fast 4G/4x
CPU profiles.
- Added Markdown, raw JSON, and Chrome-trace outputs with median
baseline tables and waterfall data.

## Verification

- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm check:token-gates`
- `npx playwright test --config
tests/perf/issue-detail/playwright.config.ts --list`
- `pnpm exec playwright test --config
tests/perf/issue-detail/playwright.config.ts` — passed 20 samples in 9.4
minutes (5 runs × 2 scenarios × 2 profiles) for the baseline;
post-review integrity reruns also exercised the corrected paths, while
this shared runner intermittently killed Chromium processes, so the rig
now performs one bounded browser-crash retry per sample.
- Baseline medians: warm unthrottled 278/447 ms header/content; cold
unthrottled 646/646 ms; warm throttled 1240/2060 ms; cold throttled
3932/3933 ms.

## Risks

- Low product risk: the new browser measurements are development/QA
tooling and the UI timing work does not change visible layout.
- `Server-Timing` exposes only aggregate handler duration, not query
contents or private identifiers.
- Native INP reporting uses supported browser event timing entries and
silently no-ops where unsupported.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5.4, tool-assisted coding and browser execution with
reasoning enabled; context-window size is not exposed in this
environment.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Dev Agent <dev@paperclip.ing>
2026-08-11 15:18:29 -04:00

100 lines
4.0 KiB
TypeScript

export const ISSUE_DETAIL_NAVIGATE_MARK = "issue-detail:navigate";
export const ISSUE_DETAIL_HEADER_PAINT_MARK = "issue-detail:header-paint";
export const ISSUE_DETAIL_CONTENT_PAINT_MARK = "issue-detail:content-paint";
export const ISSUE_DETAIL_HEADER_MEASURE = "issue-detail:navigate→header-paint";
export const ISSUE_DETAIL_CONTENT_MEASURE = "issue-detail:navigate→content-paint";
let issueDetailNavigationGeneration = 0;
declare global {
interface Window {
__PAPERCLIP_ISSUE_DETAIL_NAVIGATE_START__?: number;
}
}
function supportsPerformanceMarks(): boolean {
return typeof window !== "undefined" && typeof performance !== "undefined" && typeof performance.mark === "function";
}
export function beginIssueDetailNavigation(): void {
issueDetailNavigationGeneration += 1;
if (!supportsPerformanceMarks()) return;
performance.clearMarks(ISSUE_DETAIL_NAVIGATE_MARK);
performance.clearMarks(ISSUE_DETAIL_HEADER_PAINT_MARK);
performance.clearMarks(ISSUE_DETAIL_CONTENT_PAINT_MARK);
performance.clearMeasures(ISSUE_DETAIL_HEADER_MEASURE);
performance.clearMeasures(ISSUE_DETAIL_CONTENT_MEASURE);
const externallyCapturedStart = window.__PAPERCLIP_ISSUE_DETAIL_NAVIGATE_START__;
delete window.__PAPERCLIP_ISSUE_DETAIL_NAVIGATE_START__;
performance.mark(ISSUE_DETAIL_NAVIGATE_MARK, {
startTime: typeof externallyCapturedStart === "number" ? externallyCapturedStart : performance.now(),
});
}
export function scheduleIssueDetailPaintMeasure(
markName: typeof ISSUE_DETAIL_HEADER_PAINT_MARK | typeof ISSUE_DETAIL_CONTENT_PAINT_MARK,
measureName: typeof ISSUE_DETAIL_HEADER_MEASURE | typeof ISSUE_DETAIL_CONTENT_MEASURE,
): void {
if (!supportsPerformanceMarks() || performance.getEntriesByName(measureName).length > 0) return;
const navigationGeneration = issueDetailNavigationGeneration;
requestAnimationFrame(() => {
if (navigationGeneration !== issueDetailNavigationGeneration) return;
requestAnimationFrame(() => {
if (navigationGeneration !== issueDetailNavigationGeneration) return;
if (performance.getEntriesByName(measureName).length > 0) return;
if (performance.getEntriesByName(ISSUE_DETAIL_NAVIGATE_MARK).length === 0) {
performance.mark(ISSUE_DETAIL_NAVIGATE_MARK);
}
performance.mark(markName);
performance.measure(measureName, ISSUE_DETAIL_NAVIGATE_MARK, markName);
});
});
}
type VitalMetric = {
name: "TTFB" | "LCP" | "INP";
value: number;
};
function reportVital(metric: VitalMetric): void {
console.info("[issue-detail:web-vital]", metric);
}
export function reportIssueDetailWebVitals(): () => void {
if (typeof window === "undefined" || typeof PerformanceObserver === "undefined") return () => undefined;
const observers: PerformanceObserver[] = [];
const navigation = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined;
if (navigation) {
reportVital({ name: "TTFB", value: navigation.responseStart - navigation.requestStart });
}
try {
const lcpObserver = new PerformanceObserver((list) => {
const latest = list.getEntries().at(-1);
if (latest) reportVital({ name: "LCP", value: latest.startTime });
});
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
observers.push(lcpObserver);
} catch {}
try {
let longestInteraction = 0;
const inpObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const interactionEntry = entry as PerformanceEventTiming & { interactionId?: number };
if (!interactionEntry.interactionId) continue;
longestInteraction = Math.max(longestInteraction, interactionEntry.duration);
}
if (longestInteraction > 0) reportVital({ name: "INP", value: longestInteraction });
});
inpObserver.observe({ type: "event", buffered: true, durationThreshold: 40 } as PerformanceObserverInit);
observers.push(inpObserver);
} catch {}
return () => observers.forEach((observer) => observer.disconnect());
}