feat: add web sentinel request-rate curves

This commit is contained in:
Codex
2026-07-02 04:51:18 +00:00
parent 9d9f8e1dca
commit f7a0b0672c
13 changed files with 921 additions and 12 deletions
@@ -54,6 +54,7 @@ const transitions = buildTransitions(samples);
const sampleMetrics = buildSampleMetrics(samples, control);
const pageProvenance = buildPageProvenanceReport(samples, control, manifest);
const pagePerformance = buildPagePerformanceReport(samples, manifest);
const requestRate = buildRequestRateReport(network);
const promptNetwork = buildPromptNetworkReport(control, promptNetworkRows);
const runtimeAlerts = buildRuntimeAlerts(samples, control, network, consoleEvents, errors);
const apiDomLag = buildApiDomLagReport(samples, network);
@@ -108,7 +109,7 @@ const runnerErrors = errors.slice(-8).map((item) => {
});
const commandFailures = summarizeCommandFailures(control);
const toolFindings = buildToolFindings({ manifest, heartbeat, commandState });
const findings = [...toolFindings, ...buildProjectManagementFindings(projectManagement), ...buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, commandFailures, manifest, apiDomLag, browserProcess)];
const findings = [...toolFindings, ...buildProjectManagementFindings(projectManagement), ...buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, requestRate, pageProvenance, commandFailures, manifest, apiDomLag, browserProcess)];
if (jsonlReadIssues.length > 0) findings.unshift({ id: "jsonl-read-issues", severity: "red", summary: "observer analyzer hit JSONL read/parse issues", count: jsonlReadIssues.length, issues: jsonlReadIssues.slice(0, 20) });
const recentWindow = buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, browserProcessRows, manifest });
const commandTimeline = control.filter((item) => item.phase === "completed" || item.phase === "failed").map((item) => ({ ts: item.ts, phase: item.phase, commandId: item.commandId, type: item.type, input: item.input, afterUrl: item.afterUrl }));
@@ -128,6 +129,7 @@ const report = {
sampleMetrics,
pageProvenance,
pagePerformance,
requestRate,
projectManagement,
promptNetwork,
runtimeAlerts,
@@ -159,6 +161,7 @@ console.log(JSON.stringify({
archiveSummary: {
sampleMetrics: sampleMetrics.summary,
pagePerformance: pagePerformance.summary,
requestRate: requestRate.summary,
projectManagement: projectManagement.summary,
runtimeAlerts: runtimeAlerts.summary,
apiDomLag: apiDomLag.summary,
@@ -180,6 +183,7 @@ console.log(JSON.stringify({
},
pageProvenance: recentWindow.pageProvenance.summary,
pagePerformance: recentWindow.pagePerformance.summary,
requestRate: recentWindow.requestRate.summary,
projectManagement: compactProjectManagementForOutput(projectManagement),
promptNetwork: recentWindow.promptNetwork.summary,
runtimeAlerts: recentWindow.runtimeAlerts.summary,
@@ -309,6 +313,8 @@ console.log(JSON.stringify({
timingSourceOfTruth: item.timingSourceOfTruth ?? null,
timingStatus: item.timingStatus ?? null,
})),
requestRateCurve: compactRequestRateForOutput(recentWindow.requestRate),
requestRatePeaks: recentWindow.requestRate.peaks.slice(0, 8),
pagePerformanceSlowApi: recentWindow.pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true && Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0) > 0).slice(0, 5).map((item) => ({ path: item.path, route: item.route, sampleCount: item.sampleCount, p95Ms: item.p95Ms, maxMs: item.maxMs, overBudgetCount: item.overBudgetCount, budgetMs: item.budgetMs, overFiveSecondCount: item.overFiveSecondCount, slowSamples: item.slowSamples })),
archivePagePerformanceSlowApi: pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true && Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0) > 0).slice(0, 8).map((item) => ({ path: item.path, route: item.route, sampleCount: item.sampleCount, p95Ms: item.p95Ms, maxMs: item.maxMs, overBudgetCount: item.overBudgetCount, budgetMs: item.budgetMs, overFiveSecondCount: item.overFiveSecondCount, slowSamples: item.slowSamples })),
pagePerformancePartialApi: recentWindow.pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true && Number(item.partialOverBudgetCount ?? item.partialOverFiveSecondCount ?? 0) > 0).slice(0, 5).map((item) => ({ path: item.path, route: item.route, sampleCount: item.sampleCount, completeTimingSampleCount: item.completeTimingSampleCount, partialTimingSampleCount: item.partialTimingSampleCount, partialOverBudgetCount: item.partialOverBudgetCount, budgetMs: item.partialBudgetMs, partialOverFiveSecondCount: item.partialOverFiveSecondCount, partialSamples: item.partialSamples })),
@@ -570,6 +576,10 @@ function parseAlertThresholds(value) {
screenshotTimeoutRedCount: requiredPositiveThreshold(raw, "screenshotTimeoutRedCount"),
pageErrorRedCount: requiredPositiveThreshold(raw, "pageErrorRedCount"),
browserProcessSampleIntervalMs: requiredPositiveThreshold(raw, "browserProcessSampleIntervalMs"),
requestRateBucketMs: requiredPositiveThreshold(raw, "requestRateBucketMs"),
requestRateTotalRedPerMinute: requiredPositiveThreshold(raw, "requestRateTotalRedPerMinute"),
requestRatePageRedPerMinute: requiredPositiveThreshold(raw, "requestRatePageRedPerMinute"),
requestRateApiPathRedPerMinute: requiredPositiveThreshold(raw, "requestRateApiPathRedPerMinute"),
browserTotalRssRedMb: requiredPositiveThreshold(raw, "browserTotalRssRedMb"),
browserProcessRssRedMb: requiredPositiveThreshold(raw, "browserProcessRssRedMb"),
browserRssGrowthRedMb: requiredPositiveThreshold(raw, "browserRssGrowthRedMb"),
@@ -3259,7 +3269,7 @@ function promptCommandHasAuthoritativeSubmitSideEffect(control, promptRound) {
|| Number(sideEffect.messageCountDelta ?? 0) > 0;
}
function buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, commandFailures = [], manifest = {}, apiDomLag = null, browserProcess = null) {
function buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, requestRate, pageProvenance, commandFailures = [], manifest = {}, apiDomLag = null, browserProcess = null) {
const findings = [];
const effectiveApiDomLag = apiDomLag || buildApiDomLagReport(samples, network);
if (commandFailures.length > 0) findings.push({ id: "observer-command-failed", severity: "red", summary: "observer control commands failed; analyze must surface command failure instead of hiding it in command artifacts", count: commandFailures.length, commands: commandFailures.slice(0, 20) });
@@ -3590,6 +3600,40 @@ function buildFindings(samples, control, network, errors, sampleMetrics, promptN
const slowStreamOpen = longLivedStreams.filter((item) => Number(item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0) > 0);
if (slowStreamOpen.length > 0) findings.push({ id: "page-performance-slow-long-lived-stream-open", severity: "red", summary: "long-lived stream open latency exceeded configured YAML usability budget; stream lifetime is still reported separately", count: slowStreamOpen.length, budgetMs: alertThresholds.longLivedStreamOpenSlowMs, groups: slowStreamOpen.slice(0, 20) });
if (longLivedStreams.length > 0) findings.push({ id: "page-performance-long-lived-streams", severity: "info", summary: "same-origin long-lived streams are reported separately; lifetime is not treated as API load latency", count: longLivedStreams.length, groups: longLivedStreams.slice(0, 20) });
const requestRatePeaks = Array.isArray(requestRate?.peaks) ? requestRate.peaks : [];
const totalRequestRatePeaks = requestRatePeaks.filter((item) => item.scope === "total" && item.overThreshold === true);
const pageRequestRatePeaks = requestRatePeaks.filter((item) => item.scope === "page" && item.overThreshold === true);
const apiPathRequestRatePeaks = requestRatePeaks.filter((item) => item.scope === "apiPath" && item.overThreshold === true);
if (totalRequestRatePeaks.length > 0) findings.push({
id: "request-rate-total-peak",
severity: "red",
summary: "same-origin API request rate exceeded the YAML total peak threshold; this is request storm evidence from browser network events",
count: totalRequestRatePeaks.length,
thresholdPerMinute: alertThresholds.requestRateTotalRedPerMinute,
bucketMs: requestRate?.summary?.bucketMs ?? alertThresholds.requestRateBucketMs,
peaks: totalRequestRatePeaks.slice(0, 20),
valuesRedacted: true
});
if (pageRequestRatePeaks.length > 0) findings.push({
id: "request-rate-page-peak",
severity: "red",
summary: "a page-level same-origin API request curve exceeded the YAML peak threshold; inspect the page and top API paths before changing probe sampling",
count: pageRequestRatePeaks.length,
thresholdPerMinute: alertThresholds.requestRatePageRedPerMinute,
bucketMs: requestRate?.summary?.bucketMs ?? alertThresholds.requestRateBucketMs,
peaks: pageRequestRatePeaks.slice(0, 20),
valuesRedacted: true
});
if (apiPathRequestRatePeaks.length > 0) findings.push({
id: "request-rate-api-path-peak",
severity: "red",
summary: "an API-path request curve exceeded the YAML peak threshold; this pinpoints the route most likely involved in a request storm",
count: apiPathRequestRatePeaks.length,
thresholdPerMinute: alertThresholds.requestRateApiPathRedPerMinute,
bucketMs: requestRate?.summary?.bucketMs ?? alertThresholds.requestRateBucketMs,
peaks: apiPathRequestRatePeaks.slice(0, 20),
valuesRedacted: true
});
if ((pageProvenance?.summary?.segmentCount ?? 0) > 1) findings.push({ id: "page-provenance-segments", severity: "info", summary: "observer crossed page asset provenance segments; interpret runtime findings by segment", segmentCount: pageProvenance.summary.segmentCount, segments: pageProvenance.segments.slice(0, 20) });
const naturalApi = network.filter((item) => item.observerInitiated === false && item.type === "response" && /\/v1\/|\/auth\//u.test(String(item.url || "")));
const apiDomLagSummary = effectiveApiDomLag?.summary || {};
@@ -4395,11 +4439,12 @@ function buildRecentAnalysisWindow({ samples, control, network, consoleEvents, e
const sampleMetrics = buildSampleMetrics(windowSamples, control);
const pageProvenance = buildPageProvenanceReport(windowSamples, windowControl, manifest);
const pagePerformance = buildPagePerformanceReport(windowSamples, manifest);
const requestRate = buildRequestRateReport(windowNetwork);
const promptNetwork = buildPromptNetworkReport(windowControl, windowNetwork);
const runtimeAlerts = buildRuntimeAlerts(windowSamples, control, windowNetwork, windowConsole, windowErrors);
const apiDomLag = buildApiDomLagReport(windowSamples, windowNetwork);
const browserProcess = buildBrowserProcessReport(windowBrowserProcessRows);
const findings = buildFindings(windowSamples, control, windowNetwork, windowErrors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, [], {}, apiDomLag, browserProcess);
const findings = buildFindings(windowSamples, control, windowNetwork, windowErrors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, requestRate, pageProvenance, [], {}, apiDomLag, browserProcess);
return {
summary: {
name: "recent-5m",
@@ -4417,6 +4462,7 @@ function buildRecentAnalysisWindow({ samples, control, network, consoleEvents, e
sampleMetrics,
pageProvenance,
pagePerformance,
requestRate,
promptNetwork,
runtimeAlerts,
apiDomLag,
@@ -4822,6 +4868,280 @@ function percentile(sortedValues, percentileValue) {
return Math.round(sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight);
}
function buildRequestRateReport(network) {
const bucketMs = Math.max(1000, Number(alertThresholds.requestRateBucketMs || 0));
const naturalApiRequests = (Array.isArray(network) ? network : [])
.filter((item) => item?.observerInitiated !== true && item?.type === "request")
.map((item) => requestRateEvent(item))
.filter((item) => item !== null)
.sort((a, b) => a.tsMs - b.tsMs);
if (naturalApiRequests.length === 0) {
return {
summary: {
bucketMs,
bucketSeconds: Number((bucketMs / 1000).toFixed(3)),
requestCount: 0,
bucketCount: 0,
pageCount: 0,
apiPathCount: 0,
totalRedPerMinute: alertThresholds.requestRateTotalRedPerMinute,
pageRedPerMinute: alertThresholds.requestRatePageRedPerMinute,
apiPathRedPerMinute: alertThresholds.requestRateApiPathRedPerMinute,
totalPeakPerMinute: 0,
pagePeakPerMinute: 0,
apiPathPeakPerMinute: 0,
overThresholdPeakCount: 0,
valuesRedacted: true,
},
buckets: [],
pageCurves: [],
apiPathCurves: [],
peaks: [],
valuesRedacted: true,
};
}
const firstBucketMs = Math.floor(naturalApiRequests[0].tsMs / bucketMs) * bucketMs;
const lastBucketMs = Math.floor(naturalApiRequests[naturalApiRequests.length - 1].tsMs / bucketMs) * bucketMs;
const makeBucket = (bucketStartMs, extra = {}) => ({
bucketStartMs,
bucketEndMs: bucketStartMs + bucketMs,
startAt: new Date(bucketStartMs).toISOString(),
endAt: new Date(bucketStartMs + bucketMs).toISOString(),
count: 0,
requestPerMinute: 0,
...extra,
valuesRedacted: true,
});
const totalBuckets = new Map();
const pageBuckets = new Map();
const apiPathBuckets = new Map();
const pageTotals = new Map();
const apiPathTotals = new Map();
const allBucketStarts = [];
for (let bucketStartMs = firstBucketMs; bucketStartMs <= lastBucketMs; bucketStartMs += bucketMs) {
totalBuckets.set(bucketStartMs, makeBucket(bucketStartMs));
allBucketStarts.push(bucketStartMs);
}
for (const item of naturalApiRequests) {
const bucketStartMs = Math.floor(item.tsMs / bucketMs) * bucketMs;
const totalBucket = totalBuckets.get(bucketStartMs) || makeBucket(bucketStartMs);
totalBucket.count += 1;
totalBuckets.set(bucketStartMs, totalBucket);
const pageKey = item.pageKey;
const pageMapKey = pageKey + "|" + bucketStartMs;
const pageBucket = pageBuckets.get(pageMapKey) || makeBucket(bucketStartMs, {
pageKey,
pageRole: item.pageRole,
pageId: item.pageId,
pageEpoch: item.pageEpoch,
path: item.framePath,
});
pageBucket.count += 1;
pageBuckets.set(pageMapKey, pageBucket);
const pageTotal = pageTotals.get(pageKey) || { pageKey, pageRole: item.pageRole, pageId: item.pageId, pageEpoch: item.pageEpoch, path: item.framePath, count: 0 };
pageTotal.count += 1;
pageTotals.set(pageKey, pageTotal);
const apiKey = item.method + " " + item.apiPath;
const apiMapKey = apiKey + "|" + bucketStartMs;
const apiBucket = apiPathBuckets.get(apiMapKey) || makeBucket(bucketStartMs, {
apiKey,
method: item.method,
path: item.apiPath,
pageKey,
pageRole: item.pageRole,
pageId: item.pageId,
pageEpoch: item.pageEpoch,
});
apiBucket.count += 1;
apiPathBuckets.set(apiMapKey, apiBucket);
const apiTotal = apiPathTotals.get(apiKey) || { apiKey, method: item.method, path: item.apiPath, count: 0 };
apiTotal.count += 1;
apiPathTotals.set(apiKey, apiTotal);
}
const finalizeBucket = (bucket) => ({
...bucket,
requestPerMinute: Number((Number(bucket.count || 0) * 60000 / bucketMs).toFixed(2)),
});
const buckets = Array.from(totalBuckets.values()).map(finalizeBucket);
const pageBucketRows = Array.from(pageBuckets.values()).map(finalizeBucket);
const apiPathBucketRows = Array.from(apiPathBuckets.values()).map(finalizeBucket);
const peakForRows = (rows, thresholdPerMinute, scope, extra = {}) => rows
.filter((row) => Number(row.requestPerMinute ?? 0) >= Number(thresholdPerMinute || Infinity))
.map((row) => ({
scope,
thresholdPerMinute,
overThreshold: true,
bucketMs,
bucketStartMs: row.bucketStartMs,
bucketEndMs: row.bucketEndMs,
startAt: row.startAt,
endAt: row.endAt,
count: row.count,
requestPerMinute: row.requestPerMinute,
...extra(row),
valuesRedacted: true,
}));
const totalPeaks = peakForRows(buckets, alertThresholds.requestRateTotalRedPerMinute, "total", () => ({}));
const pagePeaks = peakForRows(pageBucketRows, alertThresholds.requestRatePageRedPerMinute, "page", (row) => ({
pageKey: row.pageKey,
pageRole: row.pageRole,
pageId: row.pageId,
pageEpoch: row.pageEpoch,
path: row.path,
}));
const apiPathPeaks = peakForRows(apiPathBucketRows, alertThresholds.requestRateApiPathRedPerMinute, "apiPath", (row) => ({
apiKey: row.apiKey,
method: row.method,
path: row.path,
pageKey: row.pageKey,
pageRole: row.pageRole,
pageId: row.pageId,
pageEpoch: row.pageEpoch,
}));
const pageCurves = Array.from(pageTotals.values())
.map((page) => {
const rows = pageBucketRows
.filter((row) => row.pageKey === page.pageKey)
.sort((a, b) => a.bucketStartMs - b.bucketStartMs);
const peak = maxByNumber(rows, (row) => row.requestPerMinute);
return {
...page,
bucketCount: rows.length,
peakRequestPerMinute: peak?.requestPerMinute ?? 0,
peakBucket: peak ?? null,
buckets: rows.slice(-60),
valuesRedacted: true,
};
})
.sort((a, b) => Number(b.peakRequestPerMinute ?? 0) - Number(a.peakRequestPerMinute ?? 0) || Number(b.count ?? 0) - Number(a.count ?? 0));
const apiPathCurves = Array.from(apiPathTotals.values())
.map((api) => {
const rows = apiPathBucketRows
.filter((row) => row.apiKey === api.apiKey)
.sort((a, b) => a.bucketStartMs - b.bucketStartMs);
const peak = maxByNumber(rows, (row) => row.requestPerMinute);
return {
...api,
bucketCount: rows.length,
peakRequestPerMinute: peak?.requestPerMinute ?? 0,
peakBucket: peak ?? null,
buckets: rows.slice(-60),
valuesRedacted: true,
};
})
.sort((a, b) => Number(b.peakRequestPerMinute ?? 0) - Number(a.peakRequestPerMinute ?? 0) || Number(b.count ?? 0) - Number(a.count ?? 0));
const totalPeak = maxByNumber(buckets, (row) => row.requestPerMinute);
const pagePeak = pageCurves[0] ?? null;
const apiPathPeak = apiPathCurves[0] ?? null;
const peaks = [...totalPeaks, ...pagePeaks, ...apiPathPeaks]
.sort((a, b) => Number(b.requestPerMinute ?? 0) - Number(a.requestPerMinute ?? 0) || String(a.scope).localeCompare(String(b.scope)));
return {
summary: {
bucketMs,
bucketSeconds: Number((bucketMs / 1000).toFixed(3)),
requestCount: naturalApiRequests.length,
bucketCount: buckets.length,
pageCount: pageCurves.length,
apiPathCount: apiPathCurves.length,
firstAt: new Date(firstBucketMs).toISOString(),
lastAt: new Date(lastBucketMs + bucketMs).toISOString(),
totalRedPerMinute: alertThresholds.requestRateTotalRedPerMinute,
pageRedPerMinute: alertThresholds.requestRatePageRedPerMinute,
apiPathRedPerMinute: alertThresholds.requestRateApiPathRedPerMinute,
totalPeakPerMinute: totalPeak?.requestPerMinute ?? 0,
totalPeakCount: totalPeak?.count ?? 0,
totalPeakAt: totalPeak?.startAt ?? null,
pagePeakPerMinute: pagePeak?.peakRequestPerMinute ?? 0,
pagePeakKey: pagePeak?.pageKey ?? null,
pagePeakPath: pagePeak?.path ?? null,
apiPathPeakPerMinute: apiPathPeak?.peakRequestPerMinute ?? 0,
apiPathPeakKey: apiPathPeak?.apiKey ?? null,
overThresholdPeakCount: peaks.length,
valuesRedacted: true,
},
buckets: buckets.slice(-120),
pageCurves: pageCurves.slice(0, 20),
apiPathCurves: apiPathCurves.slice(0, 40),
peaks: peaks.slice(0, 80),
valuesRedacted: true,
};
}
function requestRateEvent(item) {
const tsMs = Date.parse(String(item?.ts || ""));
const apiPath = normalizeApiRatePath(urlPath(item?.url));
if (!Number.isFinite(tsMs) || !isApiLikePath(apiPath)) return null;
const framePath = normalizeRoutePath(urlPath(item?.frameUrl || item?.url));
const pageRole = String(item?.pageRole || "unknown");
const pageId = String(item?.pageId || "unknown");
const pageEpoch = Number.isFinite(Number(item?.pageEpoch)) ? Number(item.pageEpoch) : null;
return {
ts: item.ts,
tsMs,
method: String(item?.method || "GET").toUpperCase(),
apiPath,
framePath,
pageRole,
pageId,
pageEpoch,
pageKey: [pageRole, pageId, pageEpoch ?? "-", framePath || "-"].join("|"),
valuesRedacted: true,
};
}
function normalizeApiRatePath(value) {
return normalizeRoutePath(value)
.replace(/\/sessions\/[^/?#]+/gu, "/sessions/:id")
.replace(/\/turns\/[^/?#]+/gu, "/turns/:id")
.replace(/\/traces\/[^/?#]+/gu, "/traces/:id")
.replace(/\/messages\/[^/?#]+/gu, "/messages/:id")
.replace(/\/files\/[^/?#]+/gu, "/files/:id")
.replace(/\/tasks\/[^/?#]+/gu, "/tasks/:id")
.replace(/\/runs\/[^/?#]+/gu, "/runs/:id")
.replace(/\/[0-9a-f]{8,}(?=\/|$)/giu, "/:id")
.replace(/\/[A-Za-z0-9_-]{24,}(?=\/|$)/gu, "/:id");
}
function normalizeRoutePath(value) {
const raw = String(value || "");
if (!raw || raw === "-") return "-";
return raw.replace(/\/+/gu, "/").replace(/\/$/u, "") || "/";
}
function compactRequestRateForOutput(report) {
if (!report || typeof report !== "object") return null;
return {
summary: report.summary ?? null,
buckets: Array.isArray(report.buckets) ? report.buckets.slice(-12) : [],
pageCurves: Array.isArray(report.pageCurves) ? report.pageCurves.slice(0, 8).map((item) => ({
pageKey: item.pageKey ?? null,
pageRole: item.pageRole ?? null,
pageId: item.pageId ?? null,
pageEpoch: item.pageEpoch ?? null,
path: item.path ?? null,
count: item.count ?? null,
peakRequestPerMinute: item.peakRequestPerMinute ?? null,
peakBucket: item.peakBucket ?? null,
buckets: Array.isArray(item.buckets) ? item.buckets.slice(-12) : [],
valuesRedacted: true,
})) : [],
apiPathCurves: Array.isArray(report.apiPathCurves) ? report.apiPathCurves.slice(0, 12).map((item) => ({
apiKey: item.apiKey ?? null,
method: item.method ?? null,
path: item.path ?? null,
count: item.count ?? null,
peakRequestPerMinute: item.peakRequestPerMinute ?? null,
peakBucket: item.peakBucket ?? null,
buckets: Array.isArray(item.buckets) ? item.buckets.slice(-12) : [],
valuesRedacted: true,
})) : [],
peaks: Array.isArray(report.peaks) ? report.peaks.slice(0, 12) : [],
valuesRedacted: true,
};
}
function buildPromptNetworkReport(control, network) {
const promptsById = new Map();
for (const item of control) {