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
@@ -951,6 +951,7 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database,
run: dashboardRunSummary(config, db, row),
summary: record(stored.summary),
memory: dashboardRunMemoryDetail(config, row, stored),
requestRate: dashboardRunRequestRateDetail(config, row, stored),
findings,
viewsAvailable: Object.keys(views),
artifacts: {
@@ -1099,6 +1100,37 @@ function dashboardRunMemoryDetail(config: WebProbeSentinelServiceConfig, row: Re
};
}
function dashboardRunRequestRateDetail(config: WebProbeSentinelServiceConfig, row: Record<string, unknown>, stored: Record<string, unknown>): Record<string, unknown> {
const options = dashboardDetailRequestRateOptions(config);
const storedRequestRate = compactDashboardRequestRate(record(record(record(stored.summary).analysis).requestRate), options, "recorded-analysis-summary");
if (storedRequestRate.ok === true && hasDashboardRequestRateSeries(storedRequestRate)) return storedRequestRate;
const fromArtifacts = readDashboardRequestRateFromAnalysisReport(config, row, options);
if (fromArtifacts.ok === true) return fromArtifacts;
if (storedRequestRate.ok === true) return storedRequestRate;
return {
ok: false,
source: "unavailable",
unit: "rpm",
metric: "same-origin-api-request-rate",
requestCount: 0,
bucketCount: 0,
pageCount: 0,
apiPathCount: 0,
totalSeries: [],
pageSeries: [],
apiPathSeries: [],
peaks: [],
reason: fromArtifacts.reason ?? storedRequestRate.reason ?? "request-rate-samples-missing",
valuesRedacted: true,
};
}
function hasDashboardRequestRateSeries(value: Record<string, unknown>): boolean {
return arrayRecords(value.totalSeries).length > 0
|| arrayRecords(value.pageSeries).length > 0
|| arrayRecords(value.apiPathSeries).length > 0;
}
function dashboardDetailMemoryOptions(config: WebProbeSentinelServiceConfig): Record<string, number> {
const detailMemory = recordTarget(valueAtPath(config.reportViews, "detailMemory"));
return {
@@ -1107,6 +1139,17 @@ function dashboardDetailMemoryOptions(config: WebProbeSentinelServiceConfig): Re
};
}
function dashboardDetailRequestRateOptions(config: WebProbeSentinelServiceConfig): Record<string, number> {
const detailRequestRate = recordTarget(valueAtPath(config.reportViews, "detailRequestRate"));
return {
maxTotalBuckets: Math.max(1, Math.floor(numberAt(detailRequestRate, "maxTotalBuckets"))),
maxPageSeries: Math.max(1, Math.floor(numberAt(detailRequestRate, "maxPageSeries"))),
maxApiPathSeries: Math.max(1, Math.floor(numberAt(detailRequestRate, "maxApiPathSeries"))),
maxBucketsPerSeries: Math.max(1, Math.floor(numberAt(detailRequestRate, "maxBucketsPerSeries"))),
maxPeaks: Math.max(1, Math.floor(numberAt(detailRequestRate, "maxPeaks"))),
};
}
function readDashboardMemoryFromStateDir(config: WebProbeSentinelServiceConfig, row: Record<string, unknown>, options: Record<string, number>): Record<string, unknown> {
const stateDir = stringOrNull(row.state_dir) ?? stringOrNull(row.stateDir);
if (stateDir === null) return { ok: false, source: "artifact-browser-process-jsonl", reason: "state-dir-missing", pageSeries: [], valuesRedacted: true };
@@ -1141,6 +1184,160 @@ function compactDashboardMemory(value: Record<string, unknown>, options: Record<
return { ok: false, source, reason: "recorded-page-series-missing", pageSeries: [], valuesRedacted: true };
}
function readDashboardRequestRateFromAnalysisReport(config: WebProbeSentinelServiceConfig, row: Record<string, unknown>, options: Record<string, number>): Record<string, unknown> {
const stateDir = stringOrNull(row.state_dir) ?? stringOrNull(row.stateDir);
if (stateDir === null) return { ok: false, source: "analysis-report-json", reason: "state-dir-missing", totalSeries: [], pageSeries: [], apiPathSeries: [], valuesRedacted: true };
if (!isSafeDashboardStateDir(stateDir)) return { ok: false, source: "analysis-report-json", reason: "unsafe-state-dir", totalSeries: [], pageSeries: [], apiPathSeries: [], valuesRedacted: true };
const reportJsonPath = join(config.stateRoot, stateDir, "analysis", "report.json");
const jsonBuffer = readFileBuffer(reportJsonPath);
if (jsonBuffer === null) return { ok: false, source: "analysis-report-json", reason: "analysis-report-json-missing", totalSeries: [], pageSeries: [], apiPathSeries: [], valuesRedacted: true };
try {
const report = record(JSON.parse(jsonBuffer.toString("utf8")) as unknown);
return compactDashboardRequestRate(record(report.requestRate), options, "analysis-report-json");
} catch (error) {
return {
ok: false,
source: "analysis-report-json",
reason: "analysis-report-json-invalid",
error: error instanceof Error ? error.message : String(error),
totalSeries: [],
pageSeries: [],
apiPathSeries: [],
valuesRedacted: true,
};
}
}
function compactDashboardRequestRate(value: Record<string, unknown>, options: Record<string, number>, source: string): Record<string, unknown> {
const summary = requestRateSummary(record(value.summary).requestCount === undefined ? value : record(value.summary));
const totalSeries = finalizeDashboardRequestRateSeries([{
key: "total",
label: "全部 API 请求",
count: summary.requestCount,
peakRequestPerMinute: summary.totalPeakPerMinute,
buckets: arrayRecords(value.buckets),
}], numberOr(options.maxTotalBuckets, 1), 1, "total");
const pageSeries = finalizeDashboardRequestRateSeries(arrayRecords(value.pageCurves), numberOr(options.maxBucketsPerSeries, 1), numberOr(options.maxPageSeries, 1), "page");
const apiPathSeries = finalizeDashboardRequestRateSeries(arrayRecords(value.apiPathCurves), numberOr(options.maxBucketsPerSeries, 1), numberOr(options.maxApiPathSeries, 1), "apiPath");
const peaks = compactDashboardRequestRatePeaks(arrayRecords(value.peaks), numberOr(options.maxPeaks, 1));
const hasRequestRate = summary.requestCount !== null || totalSeries.length > 0 || pageSeries.length > 0 || apiPathSeries.length > 0 || peaks.length > 0;
if (!hasRequestRate) {
return { ok: false, source, reason: "request-rate-report-missing", totalSeries: [], pageSeries: [], apiPathSeries: [], peaks: [], valuesRedacted: true };
}
return {
ok: true,
source,
unit: "rpm",
metric: "same-origin-api-request-rate",
...summary,
totalSeries,
pageSeries,
apiPathSeries,
peaks,
valuesRedacted: true,
};
}
function requestRateSummary(value: Record<string, unknown>): Record<string, unknown> {
return {
bucketMs: numberOrNull(value.bucketMs),
bucketSeconds: numberOrNull(value.bucketSeconds),
requestCount: numberOrNull(value.requestCount),
bucketCount: numberOrNull(value.bucketCount),
pageCount: numberOrNull(value.pageCount),
apiPathCount: numberOrNull(value.apiPathCount),
firstAt: stringOrNull(value.firstAt),
lastAt: stringOrNull(value.lastAt),
totalRedPerMinute: numberOrNull(value.totalRedPerMinute),
pageRedPerMinute: numberOrNull(value.pageRedPerMinute),
apiPathRedPerMinute: numberOrNull(value.apiPathRedPerMinute),
totalPeakPerMinute: numberOrNull(value.totalPeakPerMinute),
totalPeakCount: numberOrNull(value.totalPeakCount),
totalPeakAt: stringOrNull(value.totalPeakAt),
pagePeakPerMinute: numberOrNull(value.pagePeakPerMinute),
pagePeakKey: stringOrNull(value.pagePeakKey),
pagePeakPath: stringOrNull(value.pagePeakPath),
apiPathPeakPerMinute: numberOrNull(value.apiPathPeakPerMinute),
apiPathPeakKey: stringOrNull(value.apiPathPeakKey),
overThresholdPeakCount: numberOrNull(value.overThresholdPeakCount),
};
}
function finalizeDashboardRequestRateSeries(rawSeries: readonly Record<string, unknown>[], maxBucketsPerSeries: number, maxSeries: number, scope: string): Record<string, unknown>[] {
return rawSeries
.map((item) => {
const points = arrayRecords(item.buckets)
.map((bucket) => ({
ts: stringOrNull(bucket.startAt),
startAt: stringOrNull(bucket.startAt),
endAt: stringOrNull(bucket.endAt),
bucketStartMs: numberOrNull(bucket.bucketStartMs),
bucketEndMs: numberOrNull(bucket.bucketEndMs),
count: numberOrNull(bucket.count),
requestPerMinute: numberOrNull(bucket.requestPerMinute),
valuesRedacted: true,
}))
.filter((point) => point.ts !== null && point.requestPerMinute !== null);
const thinned = thinDashboardPoints(points, maxBucketsPerSeries);
const peakRequestPerMinute = numberOrNull(item.peakRequestPerMinute) ?? maxNumber(thinned.map((point) => numberOrNull(point.requestPerMinute))) ?? numberOrNull(item.totalPeakPerMinute) ?? 0;
const count = numberOrNull(item.count) ?? numberOrNull(item.requestCount) ?? thinned.reduce((sum, point) => sum + numberOr(point.count, 0), 0);
return {
key: requestRateSeriesKey(item, scope),
scope,
label: requestRateSeriesLabel(item, scope),
method: stringOrNull(item.method),
path: stringOrNull(item.path),
pageRole: stringOrNull(item.pageRole),
pageId: stringOrNull(item.pageId),
pageEpoch: numberOrNull(item.pageEpoch),
count,
bucketCount: numberOrNull(item.bucketCount) ?? thinned.length,
peakRequestPerMinute,
peakAt: stringOrNull(record(item.peakBucket).startAt),
points: thinned,
valuesRedacted: true,
};
})
.filter((item) => arrayRecords(item.points).length > 0)
.sort((a, b) => numberOr(b.peakRequestPerMinute, 0) - numberOr(a.peakRequestPerMinute, 0) || numberOr(b.count, 0) - numberOr(a.count, 0))
.slice(0, maxSeries);
}
function compactDashboardRequestRatePeaks(rawPeaks: readonly Record<string, unknown>[], maxPeaks: number): Record<string, unknown>[] {
return rawPeaks.slice(0, maxPeaks).map((item) => ({
scope: stringOrNull(item.scope),
thresholdPerMinute: numberOrNull(item.thresholdPerMinute),
overThreshold: item.overThreshold === true,
bucketMs: numberOrNull(item.bucketMs),
startAt: stringOrNull(item.startAt),
endAt: stringOrNull(item.endAt),
count: numberOrNull(item.count),
requestPerMinute: numberOrNull(item.requestPerMinute),
method: stringOrNull(item.method),
path: stringOrNull(item.path),
apiKey: stringOrNull(item.apiKey),
pageKey: stringOrNull(item.pageKey),
pageRole: stringOrNull(item.pageRole),
pageId: stringOrNull(item.pageId),
pageEpoch: numberOrNull(item.pageEpoch),
valuesRedacted: true,
}));
}
function requestRateSeriesKey(item: Record<string, unknown>, scope: string): string {
const fallback = [stringOrNull(item.method), stringOrNull(item.path)].filter((part) => part !== null).join(" ");
return stringOrNull(item.key)
?? stringOrNull(item.apiKey)
?? stringOrNull(item.pageKey)
?? (fallback.length > 0 ? fallback : scope);
}
function requestRateSeriesLabel(item: Record<string, unknown>, scope: string): string {
if (scope === "total") return "全部 API 请求";
if (scope === "apiPath") return (stringOrNull(item.apiKey) ?? [stringOrNull(item.method), stringOrNull(item.path)].filter((part) => part !== null).join(" ")) || "API";
return stringOrNull(item.path) ?? stringOrNull(item.pageKey) ?? stringOrNull(item.pageRole) ?? "page";
}
function buildDashboardMemoryDetail(rows: readonly Record<string, unknown>[], options: Record<string, number>, source: string): Record<string, unknown> {
const seriesByKey = new Map<string, Record<string, unknown>>();
let firstTsMs: number | null = null;