gods-eye-view/scripts/qa-traffic-baseline.mjs

478 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* qa-traffic-baseline.mjs — Traffic Phase 0 causal-chain capture.
*
* Drives four client/cache conditions and prints the User Timing segments
* emitted by `src/data/traffic.js` when `?trafficDebug=1` is present:
*
* 1. exact-viewport revisit (traffic module's full-road cache),
* 2. adjacent 0.42 km pan (new exact bbox with heavy spatial overlap),
* 3. cold city (no traffic-module entry for that city),
* 4. degraded upstream (synthetic delay on `/api/overpass` only).
*
* The proxy's actual `X-Overpass-Cache` result is printed for every network
* pass, so HIT/DISK/MISS/STALE/INFLIGHT is evidence rather than inference.
* The degraded leg delays request release in Puppeteer; it exercises the same
* fetch-start→response boundary without adding a failure switch to the
* application or proxy. Pass `--degraded-delay-ms 0` to skip that leg.
* TomTom status is intercepted as keyless so no live-flow key is required and
* the capture isolates the OSM road-to-visible-dot chain.
*
* Measurement caveat: durations include instrumentation overhead (observer
* effect), and timestamps are subject to browser clock-floor quantization.
* Per-road clock observations also sit inside `road-parse-total` and
* `waypoint-materialization`, so those segments slightly overstate production.
*
* Run with deterministic software GL (relative comparisons only):
* node scripts/qa-traffic-baseline.mjs --url http://localhost:4173
*
* Run headful on the owner's real-GPU browser surface for reportable numbers:
* node scripts/qa-traffic-baseline.mjs --url http://localhost:4173 --headful
*
* SwiftShader `scene.sampleHeight` durations are NOT representative of a real
* GPU. The script prints the detected renderer and repeats this warning when
* software rendering is detected. It does not write baseline numbers or edit
* the latency plan.
*/
import puppeteer from 'puppeteer';
import fs from 'node:fs';
const argv = process.argv.slice(2);
const getOpt = (name, dflt) => {
const index = argv.indexOf(name);
return index >= 0 && argv[index + 1] ? argv[index + 1] : dflt;
};
const getNumberOpt = (name, dflt) => {
const value = Number(getOpt(name, dflt));
return Number.isFinite(value) ? value : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
const HEADFUL = argv.includes('--headful');
const TIMEOUT_MS = Math.max(10_000, getNumberOpt('--timeout-ms', 120_000));
const DEGRADED_DELAY_MS = Math.max(0, getNumberOpt('--degraded-delay-ms', 2500));
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
].filter(Boolean);
function findChromeExecutable() {
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
try { if (fs.existsSync(candidate)) return candidate; } catch { /* ignore */ }
}
return null;
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const VIEWS = {
austin: {
lon: -97.7431, lat: 30.2672, height: 1500, heading: 8, pitch: -78,
},
adjacent: {
// 0.0105° longitude at Austin's latitude is ~1.0 km.
lon: -97.7326, lat: 30.2672, height: 1500, heading: 8, pitch: -78,
},
coldCity: {
lon: -122.4194, lat: 37.7749, height: 1500, heading: 18, pitch: -78,
},
degraded: {
lon: -98.4936, lat: 29.4241, height: 1500, heading: 12, pitch: -78,
},
};
/** Add `trafficDebug=1` while preserving the caller's path/query/hash. */
function debugUrl(rawUrl) {
const url = new URL(rawUrl);
url.searchParams.set('trafficDebug', '1');
return url.toString();
}
/** Position the camera and explicitly raise the boundaries the layer observes. */
async function moveCamera(page, view) {
return page.evaluate((v) => {
const gev = window.__godsEyeView;
const module = gev.dataManager.layers.get('traffic').module;
const beforeLastUpdate = module.getStats().lastUpdate;
const boundaryTime = performance.now();
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
const radians = Math.PI / 180;
try { gev.viewer.camera.cancelFlight(); } catch { /* no active flight */ }
gev.viewer.camera.setView({
destination: ellipsoid.cartographicToCartesian({
longitude: v.lon * radians,
latitude: v.lat * radians,
height: v.height,
}),
orientation: {
heading: (v.heading || 0) * radians,
pitch: (v.pitch ?? -90) * radians,
roll: 0,
},
});
// setView normally propagates through Cesium's render loop. Raising these
// explicitly makes the capture boundary deterministic across GPU speeds.
// Pairing still flows through camera.changed's schedule-time anchor; the
// synthetic moveEnd is emitted only as the same diagnostic used naturally.
gev.viewer.camera.changed.raiseEvent();
gev.viewer.camera.moveEnd.raiseEvent();
return { beforeLastUpdate, boundaryTime };
}, view);
}
/** Wait until the triggered load finishes and its final render reaches postRender. */
async function waitForTrafficCapture(page, beforeLastUpdate, boundaryTime) {
await page.waitForFunction(
(before, boundary) => {
const module = window.__godsEyeView?.dataManager?.layers?.get('traffic')?.module;
if (!module) return false;
const stats = module.getStats();
if (stats.lastUpdate === before || stats.count <= 0 || stats.loading) return false;
const entries = performance.getEntriesByType('measure')
.filter((entry) => entry.startTime >= boundary);
const renderCount = entries.filter((entry) => entry.name.startsWith('traffic:dot-construction:')).length;
const postRenderCount = entries.filter((entry) => entry.name.startsWith('traffic:render-to-post-render:')).length;
return renderCount > 0 && postRenderCount >= renderCount;
},
{ timeout: TIMEOUT_MS, polling: 100 },
beforeLastUpdate,
boundaryTime,
);
// Let a final full-pass postRender land after `_fetching` flips false.
await sleep(150);
}
/** Return serializable Traffic User Timing measures created after a boundary. */
function readTrafficMeasures(page, boundaryTime) {
return page.evaluate((boundary) => performance.getEntriesByType('measure')
.filter((entry) => entry.name.startsWith('traffic:') && entry.startTime >= boundary)
.map((entry) => ({
name: entry.name,
startTime: entry.startTime,
duration: entry.duration,
detail: entry.detail,
})), boundaryTime);
}
/** Drive one measured state and return its raw User Timing measures. */
async function captureState(page, name, view, setDelay) {
setDelay(name === 'degraded-upstream' ? DEGRADED_DELAY_MS : 0);
const { beforeLastUpdate, boundaryTime } = await moveCamera(page, view);
await waitForTrafficCapture(page, beforeLastUpdate, boundaryTime);
const measures = await readTrafficMeasures(page, boundaryTime);
setDelay(0);
return { name, measures };
}
/** Format a duration without hiding zero-duration measurements. */
function ms(value) {
return Number.isFinite(value) ? value.toFixed(1) : '—';
}
/** Find one segment for the same trace/pass as a render measure. */
function matchingMeasure(measures, render, segment) {
return measures.find((entry) => (
entry.detail?.segment === segment
&& entry.detail?.traceId === render.detail?.traceId
&& entry.detail?.interactionId === render.detail?.interactionId
&& entry.detail?.generation === render.detail?.generation
&& entry.detail?.pass === render.detail?.pass
));
}
/** Fail loudly instead of filtering away a structurally mispaired trace. */
function assertInteractionIntegrity(capture) {
const interactionByTrace = new Map();
for (const entry of capture.measures) {
const traceId = entry.detail?.traceId;
if (traceId == null) continue;
const interactionId = entry.detail?.interactionId;
if (interactionId == null) {
throw new Error(`${capture.name}: trace ${traceId} has no scheduling interactionId`);
}
const prior = interactionByTrace.get(traceId);
if (prior != null && prior !== interactionId) {
throw new Error(
`${capture.name}: trace ${traceId} crossed interactions ${prior} and ${interactionId}`,
);
}
interactionByTrace.set(traceId, interactionId);
}
}
/** Convert raw measures into one table row per major/full visible render. */
function rowsForCapture(capture) {
// Correlation violations are capture failures, never rows silently removed
// by generation/settle predicates that are tautological after the guard.
assertInteractionIntegrity(capture);
const renders = capture.measures.filter((entry) => (
entry.detail?.segment === 'dot-construction'
&& entry.detail?.interactionId != null
));
return renders.map((render) => {
const get = (segment) => matchingMeasure(capture.measures, render, segment);
const fetchStart = get('last-camera-change-to-fetch-start');
const response = get('fetch-to-response');
const responseJson = get('response-json');
const sample = get('sample-height-total');
const materialize = get('waypoint-materialization');
const flowRace = get('flow-render-race');
const postRender = capture.measures.find((entry) => (
entry.detail?.segment === 'render-to-post-render'
&& entry.detail?.traceId === render.detail?.traceId
&& entry.detail?.interactionId === render.detail?.interactionId
&& entry.detail?.generation === render.detail?.generation
&& entry.detail?.renderId === render.detail?.renderId
));
const visible = capture.measures.find((entry) => (
entry.detail?.segment === 'last-camera-change-to-first-visible'
&& entry.detail?.traceId === render.detail?.traceId
&& entry.detail?.interactionId === render.detail?.interactionId
&& entry.detail?.generation === render.detail?.generation
&& entry.detail?.renderId === render.detail?.renderId
));
const heatLines = capture.measures.find((entry) => (
entry.detail?.segment === 'rebuild-heat-lines'
&& entry.detail?.traceId === render.detail?.traceId
&& entry.detail?.interactionId === render.detail?.interactionId
&& entry.detail?.generation === render.detail?.generation
&& entry.detail?.renderId === render.detail?.renderId
));
const metadata = response?.detail || sample?.detail || render.detail || {};
return {
State: capture.name,
Pass: render.detail?.pass || '—',
Source: render.detail?.source || '—',
'Proxy cache': metadata.proxyCache || '—',
Upstream: metadata.proxyUpstream || '—',
'Last change→fetch ms': ms(fetchStart?.duration),
'Fetch→response ms': ms(response?.duration),
'response.json ms': ms(responseJson?.duration),
'Flow race ms': ms(flowRace?.duration),
'sampleHeight ms': ms(sample?.duration),
Calls: sample?.detail?.sampleHeightCalls ?? '—',
'Mean/call ms': ms(sample?.detail?.sampleHeightMeanMs),
Cells: sample?.detail?.distinctCells ?? '—',
'Waypoints ms': ms(materialize?.duration),
'Dots ms': ms(render.duration),
'Heat lines ms': ms(heatLines?.duration),
'Render→postRender ms': ms(postRender?.duration),
'Last change→visible ms': ms(visible?.duration),
Roads: render.detail?.roadCount ?? sample?.detail?.roadCount ?? '—',
Dots: render.detail?.dotCount ?? '—',
};
});
}
/** Read the active WebGL renderer for the real-GPU/SwiftShader warning. */
function readRenderer(page) {
return page.evaluate(() => {
const gl = window.__godsEyeView?.viewer?.scene?.context?._gl;
if (!gl) return 'unknown';
const extension = gl.getExtension('WEBGL_debug_renderer_info');
return extension
? gl.getParameter(extension.UNMASKED_RENDERER_WEBGL)
: gl.getParameter(gl.RENDERER);
});
}
async function main() {
const url = debugUrl(APP_URL);
console.log('\nTraffic Phase 0 — causal-chain baseline capture');
console.log(` App URL : ${url}`);
console.log(` Browser mode : ${HEADFUL ? 'headful (real GPU expected)' : 'headless SwiftShader (relative-only)'}`);
console.log(` Degraded delay : ${DEGRADED_DELAY_MS ? `${DEGRADED_DELAY_MS} ms/request` : 'skipped'}`);
try {
const response = await fetch(APP_URL);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
} catch (error) {
console.error(`\nDev server not reachable at ${APP_URL} (${error.message}).`);
process.exit(2);
}
const executablePath = findChromeExecutable();
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
...(executablePath ? { executablePath } : {}),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1440,900',
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
let activeProxyDelayMs = 0;
const captures = [];
const consoleErrors = [];
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
await page.evaluateOnNewDocument(() => localStorage.clear());
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
page.on('pageerror', (error) => consoleErrors.push(error.message));
// Force keyless traffic and optionally hold Overpass requests to model a
// degraded proxy/upstream. No server key or proxy debug endpoint is needed.
await page.setRequestInterception(true);
page.on('request', async (request) => {
if (request.url().includes('/api/tomtom/status')) {
await request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ hasKey: false }),
});
return;
}
if (activeProxyDelayMs > 0 && request.url().includes('/api/overpass')) {
await sleep(activeProxyDelayMs);
}
try { await request.continue(); } catch { /* page closed or already handled */ }
});
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: 60_000 },
);
// Disable all persisted overlays, park above traffic's activation ceiling,
// then enable traffic without accidentally starting an unmeasured load.
await page.evaluate(async (highView) => {
const gev = window.__godsEyeView;
await gev.dataManager.restoreEnabledLayerIds([]);
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
const radians = Math.PI / 180;
gev.viewer.camera.setView({
destination: ellipsoid.cartographicToCartesian({
longitude: highView.lon * radians,
latitude: highView.lat * radians,
height: 9000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
await gev.dataManager.setEnabled('traffic', true);
gev.viewer.camera.changed.raiseEvent();
gev.viewer.camera.moveEnd.raiseEvent();
}, VIEWS.austin);
await sleep(500);
const renderer = await readRenderer(page);
const softwareRenderer = /swiftshader|software/i.test(renderer);
console.log(` WebGL renderer : ${renderer}`);
if (!HEADFUL || softwareRenderer) {
console.log(' WARNING : sampleHeight timings are relative-only under SwiftShader/software GL.');
}
// Prime Austin once. Its measurements are deliberately not reported.
console.log('\nPriming the exact Austin viewport (not part of the table)...');
const prime = await captureState(page, 'prime', VIEWS.austin, (msValue) => {
activeProxyDelayMs = msValue;
});
if (!prime.measures.some((entry) => entry.detail?.segment === 'dot-construction')) {
throw new Error('trafficDebug instrumentation emitted no dot-construction measure');
}
// Clear the last-bounds gate without clearing the module's road cache, then
// revisit the byte-identical camera pose for a true client-cache hit.
await page.evaluate((view) => {
const gev = window.__godsEyeView;
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
const radians = Math.PI / 180;
gev.viewer.camera.setView({
destination: ellipsoid.cartographicToCartesian({
longitude: view.lon * radians,
latitude: view.lat * radians,
height: 9000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
gev.viewer.camera.changed.raiseEvent();
gev.viewer.camera.moveEnd.raiseEvent();
}, VIEWS.austin);
await sleep(500);
captures.push(await captureState(page, 'exact-viewport revisit', VIEWS.austin, (msValue) => {
activeProxyDelayMs = msValue;
}));
captures.push(await captureState(page, 'adjacent ~1.0 km pan', VIEWS.adjacent, (msValue) => {
activeProxyDelayMs = msValue;
}));
captures.push(await captureState(page, 'cold city (client)', VIEWS.coldCity, (msValue) => {
activeProxyDelayMs = msValue;
}));
if (DEGRADED_DELAY_MS > 0) {
captures.push(await captureState(page, 'degraded-upstream', VIEWS.degraded, (msValue) => {
activeProxyDelayMs = msValue;
}));
}
const missingStates = captures.filter((capture) => rowsForCapture(capture).length === 0);
if (missingStates.length) {
throw new Error(`no visible render captured for: ${missingStates.map((capture) => capture.name).join(', ')}`);
}
const exactRows = rowsForCapture(captures[0]);
if (!exactRows.some((row) => row.Source === 'client-cache' && row['Fetch→response ms'] === '—')) {
throw new Error('exact-viewport revisit did not use the traffic module cache');
}
for (const capture of captures.slice(1)) {
if (!capture.measures.some((entry) => entry.detail?.segment === 'fetch-to-response')) {
throw new Error(`${capture.name} did not issue an Overpass request`);
}
}
if (DEGRADED_DELAY_MS > 0) {
const degraded = captures.find((capture) => capture.name === 'degraded-upstream');
const delayed = degraded?.measures.some((entry) => (
entry.detail?.segment === 'fetch-to-response'
&& entry.duration >= DEGRADED_DELAY_MS * 0.9
));
if (!delayed) throw new Error('degraded-upstream leg did not observe the synthetic delay');
}
const rows = captures.flatMap(rowsForCapture);
if (!rows.length) throw new Error('no measured traffic renders were captured');
console.log('\nCausal segments (one row per visible major/full pass)');
console.table(rows);
console.log('\nNotes:');
console.log(' - Render→postRender is the first visible-frame boundary used by Phase 0.');
console.log(' - Proxy cache is read from X-Overpass-Cache; “—” means the client cache avoided fetch.');
console.log(' - response.json includes both body transfer and JSON decoding; the production path does not split them.');
console.log(' - Last change→fetch is approximately the 320 ms FETCH_DEBOUNCE for the first fetch by construction.');
console.log(' - Full-pass Last change→fetch also includes the intentionally sequential major pass.');
console.log(' - Cesium moveEnd is a diagnostic raised ~500 ms after stillness, typically after fetch starts; fetch never waits for it.');
console.log(' - Synthetic events bypass camera percentageChanged/inertia but use the same camera.changed schedule-time anchor as natural captures.');
console.log(' - The keyless capture zeroes Flow race and Heat lines work; live-keyed numbers will differ.');
console.log(' - Measurement caveat: timings include instrumentation overhead (observer effect) and browser clock-floor quantization.');
console.log(' - Per-road clock observations inside road-parse-total/waypoint-materialization slightly overstate production work.');
const timingDiagnostics = await page.evaluate(() => (
window.__godsEyeView.dataManager.layers.get('traffic').module.getStats().trafficTiming
));
console.log(` - Correlation drops: ${timingDiagnostics?.uncorrelatedTracesDropped ?? 'unavailable'} scheduling-anchor mismatch(es).`);
if (!HEADFUL || softwareRenderer) {
console.log(' - Do not copy these GPU-side numbers into the plan; rerun headful on a real-GPU surface.');
}
if (consoleErrors.length) {
console.log(` - Browser console errors observed: ${consoleErrors.length}`);
for (const error of consoleErrors.slice(0, 5)) console.log(` ${error}`);
}
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error('\nTraffic baseline capture failed:', error);
process.exit(3);
});