459 lines
23 KiB
TypeScript
459 lines
23 KiB
TypeScript
// SPEC: PJ2026-01040111 long-running Workbench observation.
|
|
// Responsibility: Analyzer recent-window, page provenance, and Navigation Timing API performance source fragment.
|
|
|
|
export function nodeWebObserveAnalyzerWindowPageSource(): string {
|
|
return String.raw`async function buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, artifacts, browserProcessRows, performanceRows, manifest }) {
|
|
const latestSampleMs = latestTimestampMs(samples);
|
|
const windowMs = 5 * 60 * 1000;
|
|
const fromMs = Number.isFinite(latestSampleMs) ? latestSampleMs - windowMs : Number.NEGATIVE_INFINITY;
|
|
const toMs = Number.isFinite(latestSampleMs) ? latestSampleMs : Number.POSITIVE_INFINITY;
|
|
const inWindow = (item) => {
|
|
const tsMs = Date.parse(item?.ts);
|
|
return Number.isFinite(tsMs) && tsMs >= fromMs && tsMs <= toMs;
|
|
};
|
|
const windowSamples = samples.filter(inWindow);
|
|
const windowControl = control.filter(inWindow);
|
|
const windowNetwork = network.filter(inWindow);
|
|
const windowConsole = consoleEvents.filter(inWindow);
|
|
const windowErrors = errors.filter(inWindow);
|
|
const windowArtifacts = (artifacts || []).filter(inWindow);
|
|
const windowBrowserProcessRows = (browserProcessRows || []).filter(inWindow);
|
|
const windowPerformanceRows = (performanceRows || []).filter(inWindow);
|
|
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 frontendPerformance = await buildFrontendPerformanceReport(windowPerformanceRows, windowArtifacts, windowSamples, windowNetwork);
|
|
const findings = buildFindings(windowSamples, control, windowNetwork, windowErrors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, requestRate, pageProvenance, [], {}, apiDomLag, browserProcess, frontendPerformance);
|
|
return {
|
|
summary: {
|
|
name: "recent-5m",
|
|
windowMs,
|
|
fromAt: Number.isFinite(fromMs) ? new Date(fromMs).toISOString() : null,
|
|
toAt: Number.isFinite(toMs) ? new Date(toMs).toISOString() : null,
|
|
samples: windowSamples.length,
|
|
control: windowControl.length,
|
|
network: windowNetwork.length,
|
|
console: windowConsole.length,
|
|
errors: windowErrors.length,
|
|
artifacts: windowArtifacts.length,
|
|
browserProcess: windowBrowserProcessRows.length,
|
|
performance: windowPerformanceRows.length,
|
|
valuesRedacted: true
|
|
},
|
|
sampleMetrics,
|
|
pageProvenance,
|
|
pagePerformance,
|
|
requestRate,
|
|
promptNetwork,
|
|
runtimeAlerts,
|
|
apiDomLag,
|
|
browserProcess,
|
|
frontendPerformance,
|
|
findings,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function latestTimestampMs(items) {
|
|
let latest = Number.NEGATIVE_INFINITY;
|
|
for (const item of items || []) {
|
|
const tsMs = Date.parse(item?.ts);
|
|
if (Number.isFinite(tsMs) && tsMs > latest) latest = tsMs;
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
function buildPageProvenanceReport(samples, control, manifest) {
|
|
const groups = new Map();
|
|
for (const sample of samples) {
|
|
const provenance = sample?.pageProvenance;
|
|
if (!provenance) continue;
|
|
const key = provenance.assetFingerprint || "unknown";
|
|
const group = groups.get(key) || {
|
|
assetFingerprint: provenance.assetFingerprint || null,
|
|
pageLoadSeqs: [],
|
|
sampleCount: 0,
|
|
firstSeq: sample.seq ?? null,
|
|
lastSeq: sample.seq ?? null,
|
|
firstAt: sample.ts ?? null,
|
|
lastAt: sample.ts ?? null,
|
|
urlPaths: [],
|
|
scriptCount: provenance.scriptCount ?? null,
|
|
stylesheetCount: provenance.stylesheetCount ?? null,
|
|
metaCount: provenance.metaCount ?? null,
|
|
scripts: Array.isArray(provenance.scripts) ? provenance.scripts.slice(0, 12) : [],
|
|
stylesheets: Array.isArray(provenance.stylesheets) ? provenance.stylesheets.slice(0, 12) : [],
|
|
valuesRedacted: true
|
|
};
|
|
group.sampleCount += 1;
|
|
group.lastSeq = sample.seq ?? null;
|
|
group.lastAt = sample.ts ?? null;
|
|
if (provenance.pageLoadSeq !== null && provenance.pageLoadSeq !== undefined && !group.pageLoadSeqs.includes(provenance.pageLoadSeq)) group.pageLoadSeqs.push(provenance.pageLoadSeq);
|
|
if (provenance.urlPath && !group.urlPaths.includes(provenance.urlPath)) group.urlPaths.push(provenance.urlPath);
|
|
groups.set(key, group);
|
|
}
|
|
const segments = Array.from(groups.values()).sort((a, b) => Number(a.firstSeq ?? 0) - Number(b.firstSeq ?? 0));
|
|
const controlSegments = control
|
|
.filter((item) => item.type === "page-provenance" || item?.pageProvenance)
|
|
.map((item) => ({
|
|
ts: item.ts ?? null,
|
|
reason: item.reason ?? item.detail?.reason ?? null,
|
|
httpStatus: item.httpStatus ?? item.detail?.httpStatus ?? null,
|
|
pageProvenance: item.pageProvenance ?? item.detail?.pageProvenance ?? null,
|
|
}))
|
|
.slice(0, 80);
|
|
return {
|
|
summary: {
|
|
segmentCount: segments.length,
|
|
sampleCount: segments.reduce((sum, item) => sum + item.sampleCount, 0),
|
|
manifestFingerprint: manifest?.pageProvenance?.assetFingerprint ?? null,
|
|
controlSegmentCount: controlSegments.length
|
|
},
|
|
segments,
|
|
controlSegments,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function buildPagePerformanceReport(samples, manifest) {
|
|
const base = manifest?.baseUrl || "http://invalid.local";
|
|
const seen = new Set();
|
|
const groups = new Map();
|
|
const sampleTimes = samples.map((sample) => Date.parse(sample?.ts || "")).filter(Number.isFinite);
|
|
const windowStartMs = sampleTimes.length > 0 ? Math.min(...sampleTimes) : null;
|
|
const windowEndMs = sampleTimes.length > 0 ? Math.max(...sampleTimes) : null;
|
|
for (const sample of samples) {
|
|
const entries = Array.isArray(sample?.performance) ? sample.performance : [];
|
|
for (const entry of entries) {
|
|
const durationMs = Number(entry?.duration);
|
|
if (!Number.isFinite(durationMs) || durationMs < 0) continue;
|
|
const entryCompletedMs = performanceEntryCompletedEpochMs(sample, entry);
|
|
if (windowStartMs !== null && entryCompletedMs !== null && entryCompletedMs < windowStartMs) continue;
|
|
if (windowEndMs !== null && entryCompletedMs !== null && entryCompletedMs > windowEndMs + 1000) continue;
|
|
const entryTs = entryCompletedMs === null ? (sample.ts ?? null) : new Date(entryCompletedMs).toISOString();
|
|
const parsed = parsePerformanceUrl(entry?.name, base);
|
|
if (!parsed.sameOrigin || !isApiLikePath(parsed.path)) continue;
|
|
const normalizedPath = normalizeApiPath(parsed.path);
|
|
const routeKind = classifyApiPerformanceRoute(normalizedPath, entry);
|
|
const isLongLivedStream = routeKind === "same-origin-api-stream";
|
|
const streamOpenMs = streamOpenLatencyMs(entry);
|
|
const timingStatus = resourceTimingPhaseStatus(entry);
|
|
const dedupeKey = [parsed.path, entry.initiatorType || "", sample?.pageProvenance?.pageLoadSeq ?? "", sample?.pageProvenance?.timeOrigin ?? "", entry.startTime ?? "", Math.round(durationMs)].join("|");
|
|
if (seen.has(dedupeKey)) continue;
|
|
seen.add(dedupeKey);
|
|
const group = groups.get(normalizedPath) || {
|
|
routeKind,
|
|
path: normalizedPath,
|
|
isLongLivedStream,
|
|
budgetMetric: isLongLivedStream ? "streamOpenMs" : "durationMs",
|
|
rawPathSamples: [],
|
|
sampleCount: 0,
|
|
completeTimingSampleCount: 0,
|
|
partialTimingSampleCount: 0,
|
|
durationsMs: [],
|
|
streamOpenDurationsMs: [],
|
|
overFiveSecondCount: 0,
|
|
overBudgetCount: 0,
|
|
partialOverFiveSecondCount: 0,
|
|
partialOverBudgetCount: 0,
|
|
streamLifetimeOverFiveSecondCount: 0,
|
|
streamOpenOverFiveSecondCount: 0,
|
|
streamOpenOverBudgetCount: 0,
|
|
firstAt: entryTs,
|
|
lastAt: entryTs,
|
|
firstSeq: sample.seq ?? null,
|
|
lastSeq: sample.seq ?? null,
|
|
initiatorTypes: [],
|
|
pageAssetFingerprints: [],
|
|
slowSamples: [],
|
|
partialSamples: [],
|
|
valuesRedacted: true
|
|
};
|
|
group.sampleCount += 1;
|
|
const partialOrdinaryTiming = !isLongLivedStream && timingStatus.status !== "complete";
|
|
let overBudget = false;
|
|
if (partialOrdinaryTiming) {
|
|
group.partialTimingSampleCount += 1;
|
|
if (durationMs > 5000) group.partialOverFiveSecondCount += 1;
|
|
if (durationMs > alertThresholds.partialApiSlowMs) {
|
|
group.partialOverBudgetCount += 1;
|
|
if (group.partialSamples.length < 80) group.partialSamples.push(compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath: parsed.path, durationMs, streamOpenMs }));
|
|
}
|
|
} else {
|
|
group.completeTimingSampleCount += 1;
|
|
group.durationsMs.push(durationMs);
|
|
if (isLongLivedStream) {
|
|
if (durationMs > 5000) group.streamLifetimeOverFiveSecondCount += 1;
|
|
if (streamOpenMs !== null) {
|
|
group.streamOpenDurationsMs.push(streamOpenMs);
|
|
if (streamOpenMs > 5000) {
|
|
group.streamOpenOverFiveSecondCount += 1;
|
|
group.overFiveSecondCount += 1;
|
|
}
|
|
if (streamOpenMs > alertThresholds.longLivedStreamOpenSlowMs) {
|
|
group.streamOpenOverBudgetCount += 1;
|
|
group.overBudgetCount += 1;
|
|
overBudget = true;
|
|
}
|
|
}
|
|
} else {
|
|
if (durationMs > 5000) group.overFiveSecondCount += 1;
|
|
if (durationMs > alertThresholds.sameOriginApiSlowMs) {
|
|
group.overBudgetCount += 1;
|
|
overBudget = true;
|
|
}
|
|
}
|
|
}
|
|
if (overBudget && group.slowSamples.length < 80) group.slowSamples.push(compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath: parsed.path, durationMs, streamOpenMs }));
|
|
group.lastAt = entryTs;
|
|
group.lastSeq = sample.seq ?? null;
|
|
if (parsed.path && !group.rawPathSamples.includes(parsed.path)) group.rawPathSamples.push(parsed.path);
|
|
if (entry.initiatorType && !group.initiatorTypes.includes(entry.initiatorType)) group.initiatorTypes.push(entry.initiatorType);
|
|
const assetFingerprint = sample?.pageProvenance?.assetFingerprint;
|
|
if (assetFingerprint && !group.pageAssetFingerprints.includes(assetFingerprint)) group.pageAssetFingerprints.push(assetFingerprint);
|
|
groups.set(normalizedPath, group);
|
|
}
|
|
}
|
|
const sameOriginApiByPath = Array.from(groups.values()).map((group) => {
|
|
const durations = group.durationsMs.slice().sort((a, b) => a - b);
|
|
const streamOpenDurations = group.streamOpenDurationsMs.slice().sort((a, b) => a - b);
|
|
return {
|
|
routeKind: group.routeKind,
|
|
path: group.path,
|
|
isLongLivedStream: group.isLongLivedStream === true,
|
|
budgetMetric: group.budgetMetric,
|
|
sampleCount: group.sampleCount,
|
|
budgetMs: group.isLongLivedStream === true ? alertThresholds.longLivedStreamOpenSlowMs : alertThresholds.sameOriginApiSlowMs,
|
|
partialBudgetMs: alertThresholds.partialApiSlowMs,
|
|
streamOpenBudgetMs: alertThresholds.longLivedStreamOpenSlowMs,
|
|
completeTimingSampleCount: group.completeTimingSampleCount,
|
|
partialTimingSampleCount: group.partialTimingSampleCount,
|
|
p50Ms: percentile(durations, 50),
|
|
p75Ms: percentile(durations, 75),
|
|
p95Ms: percentile(durations, 95),
|
|
maxMs: durations.length > 0 ? durations[durations.length - 1] : null,
|
|
streamOpenSampleCount: streamOpenDurations.length,
|
|
streamOpenP50Ms: percentile(streamOpenDurations, 50),
|
|
streamOpenP75Ms: percentile(streamOpenDurations, 75),
|
|
streamOpenP95Ms: percentile(streamOpenDurations, 95),
|
|
streamOpenMaxMs: streamOpenDurations.length > 0 ? streamOpenDurations[streamOpenDurations.length - 1] : null,
|
|
streamOpenOverFiveSecondCount: group.streamOpenOverFiveSecondCount,
|
|
streamOpenOverBudgetCount: group.streamOpenOverBudgetCount,
|
|
streamLifetimeOverFiveSecondCount: group.streamLifetimeOverFiveSecondCount,
|
|
overFiveSecondCount: group.overFiveSecondCount,
|
|
overBudgetCount: group.overBudgetCount,
|
|
partialOverFiveSecondCount: group.partialOverFiveSecondCount,
|
|
partialOverBudgetCount: group.partialOverBudgetCount,
|
|
overFiveSecondRatio: group.sampleCount > 0 ? Number((group.overFiveSecondCount / group.sampleCount).toFixed(3)) : 0,
|
|
overBudgetRatio: group.sampleCount > 0 ? Number((group.overBudgetCount / group.sampleCount).toFixed(3)) : 0,
|
|
firstAt: group.firstAt,
|
|
lastAt: group.lastAt,
|
|
firstSeq: group.firstSeq,
|
|
lastSeq: group.lastSeq,
|
|
initiatorTypes: group.initiatorTypes,
|
|
rawPathSamples: group.rawPathSamples.slice(0, 8),
|
|
pageAssetFingerprints: group.pageAssetFingerprints.slice(0, 8),
|
|
slowSamples: group.slowSamples
|
|
.slice()
|
|
.sort((a, b) => Number(b.durationMs ?? 0) - Number(a.durationMs ?? 0))
|
|
.slice(0, 12),
|
|
partialSamples: group.partialSamples
|
|
.slice()
|
|
.sort((a, b) => Number(b.durationMs ?? 0) - Number(a.durationMs ?? 0))
|
|
.slice(0, 12),
|
|
valuesRedacted: true
|
|
};
|
|
}).sort((a, b) => (Number(b.overBudgetCount ?? b.overFiveSecondCount ?? 0) - Number(a.overBudgetCount ?? a.overFiveSecondCount ?? 0)) || (Number(b.p95Ms ?? 0) - Number(a.p95Ms ?? 0)) || a.path.localeCompare(b.path));
|
|
const longLivedStreams = sameOriginApiByPath.filter((item) => item.isLongLivedStream);
|
|
const ordinaryApi = sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true);
|
|
const slow = ordinaryApi.filter((item) => Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0) > 0);
|
|
const slowFiveSecond = ordinaryApi.filter((item) => Number(item.overFiveSecondCount ?? 0) > 0);
|
|
const partialSlow = ordinaryApi.filter((item) => Number(item.partialOverBudgetCount ?? item.partialOverFiveSecondCount ?? 0) > 0);
|
|
const partialFiveSecond = ordinaryApi.filter((item) => Number(item.partialOverFiveSecondCount ?? 0) > 0);
|
|
const slowStreamOpen = longLivedStreams.filter((item) => Number(item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0) > 0);
|
|
const slowStreamOpenFiveSecond = longLivedStreams.filter((item) => Number(item.streamOpenOverFiveSecondCount ?? 0) > 0);
|
|
const budgetP95Values = sameOriginApiByPath
|
|
.map((item) => Number(item.isLongLivedStream ? (item.streamOpenP95Ms ?? 0) : (item.p95Ms ?? 0)))
|
|
.filter((value) => Number.isFinite(value));
|
|
return {
|
|
summary: {
|
|
budgetMs: alertThresholds.sameOriginApiSlowMs,
|
|
alertThresholds,
|
|
sameOriginApiPathCount: sameOriginApiByPath.length,
|
|
sameOriginApiSampleCount: sameOriginApiByPath.reduce((sum, item) => sum + item.sampleCount, 0),
|
|
longLivedStreamPathCount: longLivedStreams.length,
|
|
longLivedStreamSampleCount: longLivedStreams.reduce((sum, item) => sum + item.sampleCount, 0),
|
|
longLivedStreamOpenOverFiveSecondPathCount: slowStreamOpenFiveSecond.length,
|
|
longLivedStreamOpenOverFiveSecondSampleCount: slowStreamOpenFiveSecond.reduce((sum, item) => sum + Number(item.streamOpenOverFiveSecondCount ?? 0), 0),
|
|
longLivedStreamOpenOverBudgetPathCount: slowStreamOpen.length,
|
|
longLivedStreamOpenOverBudgetSampleCount: slowStreamOpen.reduce((sum, item) => sum + Number(item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0), 0),
|
|
longLivedStreamLifetimeOverFiveSecondSampleCount: longLivedStreams.reduce((sum, item) => sum + Number(item.streamLifetimeOverFiveSecondCount ?? 0), 0),
|
|
slowPathCount: slow.length,
|
|
slowSampleCount: slow.reduce((sum, item) => sum + Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0), 0),
|
|
overFiveSecondPathCount: slowFiveSecond.length,
|
|
overFiveSecondSampleCount: slowFiveSecond.reduce((sum, item) => sum + Number(item.overFiveSecondCount ?? 0), 0),
|
|
partialTimingSampleCount: ordinaryApi.reduce((sum, item) => sum + Number(item.partialTimingSampleCount ?? 0), 0),
|
|
partialOverFiveSecondPathCount: partialFiveSecond.length,
|
|
partialOverFiveSecondSampleCount: partialFiveSecond.reduce((sum, item) => sum + Number(item.partialOverFiveSecondCount ?? 0), 0),
|
|
partialOverBudgetPathCount: partialSlow.length,
|
|
partialOverBudgetSampleCount: partialSlow.reduce((sum, item) => sum + Number(item.partialOverBudgetCount ?? item.partialOverFiveSecondCount ?? 0), 0),
|
|
worstP95Ms: budgetP95Values.length > 0 ? Math.max(...budgetP95Values) : null,
|
|
valuesRedacted: true
|
|
},
|
|
sameOriginApiByPath,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function performanceEntryCompletedEpochMs(sample, entry) {
|
|
const origin = Number(sample?.pageProvenance?.timeOrigin);
|
|
const responseEnd = Number(entry?.responseEnd);
|
|
const startTime = Number(entry?.startTime);
|
|
const offset = Number.isFinite(responseEnd) && responseEnd > 0 ? responseEnd : startTime;
|
|
if (Number.isFinite(origin) && origin > 0 && Number.isFinite(offset) && offset >= 0) return Math.round(origin + offset);
|
|
const sampleTs = Date.parse(sample?.ts || "");
|
|
return Number.isFinite(sampleTs) ? sampleTs : null;
|
|
}
|
|
|
|
function compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath, durationMs, streamOpenMs }) {
|
|
const timingStatus = resourceTimingPhaseStatus(entry);
|
|
const serverTiming = compactServerTiming(entry?.serverTiming);
|
|
return {
|
|
ts: entryTs ?? sample?.ts ?? null,
|
|
sampleTs: sample?.ts ?? null,
|
|
seq: sample?.seq ?? null,
|
|
path: normalizedPath ?? null,
|
|
rawPath: rawPath ?? null,
|
|
initiatorType: entry?.initiatorType ?? null,
|
|
durationMs: roundFinite(durationMs),
|
|
startTimeMs: roundFinite(entry?.startTime),
|
|
fetchStartMs: roundFinite(entry?.fetchStart),
|
|
requestStartMs: roundFinite(entry?.requestStart),
|
|
responseStartMs: roundFinite(entry?.responseStart),
|
|
responseEndMs: roundFinite(entry?.responseEnd),
|
|
streamOpenMs: roundFinite(streamOpenMs),
|
|
dnsMs: phaseDeltaMs(entry, "domainLookupEnd", "domainLookupStart"),
|
|
tcpMs: phaseDeltaMs(entry, "connectEnd", "connectStart"),
|
|
tlsStartMs: roundFinite(entry?.secureConnectionStart),
|
|
requestToResponseStartMs: phaseDeltaMs(entry, "responseStart", "requestStart"),
|
|
responseTransferMs: phaseDeltaMs(entry, "responseEnd", "responseStart"),
|
|
timingStatus: timingStatus.status,
|
|
invalidTimingPhases: timingStatus.invalidPhases,
|
|
partialTimingPhases: timingStatus.partialPhases,
|
|
transferSize: Number.isFinite(Number(entry?.transferSize)) ? Number(entry.transferSize) : null,
|
|
encodedBodySize: Number.isFinite(Number(entry?.encodedBodySize)) ? Number(entry.encodedBodySize) : null,
|
|
decodedBodySize: Number.isFinite(Number(entry?.decodedBodySize)) ? Number(entry.decodedBodySize) : null,
|
|
nextHopProtocol: entry?.nextHopProtocol ?? null,
|
|
serverTiming,
|
|
serverTimingNames: serverTiming.map((item) => item.name).filter(Boolean).slice(0, 8),
|
|
otelTraceId: extractOtelTraceIdFromServerTiming(serverTiming),
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function phaseDeltaMs(entry, endKey, startKey) {
|
|
const end = Number(entry?.[endKey]);
|
|
const start = Number(entry?.[startKey]);
|
|
if (!Number.isFinite(end) || !Number.isFinite(start) || end <= 0 || start <= 0 || end < start) return null;
|
|
return Math.round(end - start);
|
|
}
|
|
|
|
function resourceTimingPhaseStatus(entry) {
|
|
const pairs = [
|
|
["requestToResponseStart", "requestStart", "responseStart"],
|
|
["responseTransfer", "responseStart", "responseEnd"],
|
|
];
|
|
const invalidPhases = [];
|
|
const partialPhases = [];
|
|
for (const [label, startKey, endKey] of pairs) {
|
|
const start = Number(entry?.[startKey]);
|
|
const end = Number(entry?.[endKey]);
|
|
if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end <= 0) {
|
|
partialPhases.push(label);
|
|
} else if (end < start) {
|
|
invalidPhases.push(label);
|
|
}
|
|
}
|
|
return {
|
|
status: invalidPhases.length > 0 ? "invalid" : (partialPhases.length > 0 ? "partial" : "complete"),
|
|
invalidPhases,
|
|
partialPhases,
|
|
};
|
|
}
|
|
|
|
function compactServerTiming(value) {
|
|
const items = Array.isArray(value) ? value : [];
|
|
return items.slice(0, 8).map((item) => ({
|
|
name: truncate(String(item?.name || ""), 80),
|
|
duration: Number.isFinite(Number(item?.duration)) ? Math.round(Number(item.duration)) : null,
|
|
description: truncate(String(item?.description || ""), 120),
|
|
})).filter((item) => item.name || item.description || item.duration !== null);
|
|
}
|
|
|
|
function extractOtelTraceIdFromServerTiming(items) {
|
|
const text = (Array.isArray(items) ? items : []).map((item) => [item.name, item.description].filter(Boolean).join(" ")).join(" ");
|
|
const match = text.match(/\b[0-9a-f]{32}\b/iu);
|
|
return match ? match[0].toLowerCase() : null;
|
|
}
|
|
|
|
function roundFinite(value) {
|
|
const numeric = Number(value);
|
|
return Number.isFinite(numeric) ? Math.round(numeric) : null;
|
|
}
|
|
|
|
function classifyApiPerformanceRoute(normalizedPath, entry = {}) {
|
|
if (normalizedPath === "/v1/workbench/events") return "same-origin-api-stream";
|
|
if (String(entry?.initiatorType ?? "").toLowerCase() === "eventsource") return "same-origin-api-stream";
|
|
return "same-origin-api";
|
|
}
|
|
|
|
function streamOpenLatencyMs(entry = {}) {
|
|
const responseStart = Number(entry?.responseStart);
|
|
const startTime = Number(entry?.startTime);
|
|
if (!Number.isFinite(responseStart) || responseStart <= 0) return null;
|
|
if (!Number.isFinite(startTime) || startTime < 0) return Math.max(0, responseStart);
|
|
if (responseStart < startTime) return null;
|
|
return Math.max(0, responseStart - startTime);
|
|
}
|
|
|
|
function parsePerformanceUrl(value, base) {
|
|
try {
|
|
const url = new URL(String(value || ""), base);
|
|
const origin = new URL(String(base || "http://invalid.local")).origin;
|
|
return { sameOrigin: url.origin === origin, path: url.pathname };
|
|
} catch {
|
|
return { sameOrigin: false, path: "-" };
|
|
}
|
|
}
|
|
|
|
function isApiLikePath(path) {
|
|
return /^\/(?:v1(?:\/|$)|auth(?:\/|$)|health(?:\/|$))/u.test(String(path || ""));
|
|
}
|
|
|
|
function normalizeApiPath(path) {
|
|
return String(path || "-")
|
|
.replace(/\/v1\/workbench\/sessions\/ses_[^/]+/gu, "/v1/workbench/sessions/:id")
|
|
.replace(/\/v1\/workbench\/turns\/trc_[^/]+/gu, "/v1/workbench/turns/:traceId")
|
|
.replace(/\/v1\/workbench\/traces\/trc_[^/]+/gu, "/v1/workbench/traces/:traceId")
|
|
.replace(/\/v1\/workbench\/sessions\/[0-9a-f-]{12,}/giu, "/v1/workbench/sessions/:id")
|
|
.replace(/\/v1\/[^/]+\/[0-9a-f-]{16,}(?=\/|$)/giu, (match) => match.replace(/\/[0-9a-f-]{16,}$/iu, "/:id"));
|
|
}
|
|
|
|
function percentile(sortedValues, percentileValue) {
|
|
if (!Array.isArray(sortedValues) || sortedValues.length === 0) return null;
|
|
if (sortedValues.length === 1) return Math.round(sortedValues[0]);
|
|
const rank = (percentileValue / 100) * (sortedValues.length - 1);
|
|
const lower = Math.floor(rank);
|
|
const upper = Math.ceil(rank);
|
|
if (lower === upper) return Math.round(sortedValues[lower]);
|
|
const weight = rank - lower;
|
|
return Math.round(sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight);
|
|
}
|
|
`;
|
|
}
|