feat: add web sentinel request-rate curves
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user