@postedin/cms-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/bin/dissect/cli.mjs +138 -0
- package/bin/dissect/dissect.mjs +290 -0
- package/bin/profile/build.mjs +106 -0
- package/bin/profile/fetch-log.mjs +298 -0
- package/bin/profile/format.mjs +90 -0
- package/bin/profile/interference-summary.mjs +558 -0
- package/bin/profile/interference.mjs +604 -0
- package/bin/profile/measure.mjs +137 -0
- package/bin/profile/report.mjs +90 -0
- package/bin/profile/site-env.mjs +16 -0
- package/bin/profile/summarize.mjs +429 -0
- package/dist/browser.d.ts +145 -0
- package/dist/browser.js +11 -0
- package/dist/browser.js.map +1 -0
- package/dist/chunk-6V54ITTK.js +197 -0
- package/dist/chunk-6V54ITTK.js.map +1 -0
- package/dist/chunk-MNZ7DIGC.js +51 -0
- package/dist/chunk-MNZ7DIGC.js.map +1 -0
- package/dist/form-proxy/upload-policy.d.ts +40 -0
- package/dist/form-proxy/upload-policy.js +17 -0
- package/dist/form-proxy/upload-policy.js.map +1 -0
- package/dist/index.d.ts +570 -0
- package/dist/index.js +1636 -0
- package/dist/index.js.map +1 -0
- package/dist/payload-types.d.ts +8985 -0
- package/dist/payload-types.js +1 -0
- package/dist/payload-types.js.map +1 -0
- package/package.json +74 -0
- package/src/api.ts +387 -0
- package/src/blog-listing.ts +75 -0
- package/src/browser.ts +24 -0
- package/src/client.ts +144 -0
- package/src/cms-to-href.ts +70 -0
- package/src/cms.ts +86 -0
- package/src/collections/appearance.ts +94 -0
- package/src/collections/areas.ts +29 -0
- package/src/collections/authors.ts +27 -0
- package/src/collections/banners.ts +14 -0
- package/src/collections/categories.ts +111 -0
- package/src/collections/forms.ts +29 -0
- package/src/collections/header-footer.ts +19 -0
- package/src/collections/image-links.ts +14 -0
- package/src/collections/media.ts +18 -0
- package/src/collections/options.ts +10 -0
- package/src/collections/pages.ts +83 -0
- package/src/collections/posts.ts +249 -0
- package/src/collections/project.ts +16 -0
- package/src/collections/questions.ts +35 -0
- package/src/collections/seo.ts +10 -0
- package/src/collections/tags.ts +25 -0
- package/src/collections/team-members.ts +79 -0
- package/src/config-time.ts +98 -0
- package/src/context.ts +12 -0
- package/src/decode-html.ts +8 -0
- package/src/form-proxy/cms-client.ts +95 -0
- package/src/form-proxy/cms-errors.ts +73 -0
- package/src/form-proxy/cms-write.ts +44 -0
- package/src/form-proxy/http.ts +96 -0
- package/src/form-proxy/index.ts +73 -0
- package/src/form-proxy/rate-limit.ts +46 -0
- package/src/form-proxy/submissions.ts +88 -0
- package/src/form-proxy/types.ts +23 -0
- package/src/form-proxy/upload-policy.ts +92 -0
- package/src/form-proxy/uploads.ts +81 -0
- package/src/home-page.ts +83 -0
- package/src/index.ts +68 -0
- package/src/loader.ts +83 -0
- package/src/locales.ts +80 -0
- package/src/payload-types.ts +10854 -0
- package/src/placeholder.ts +9 -0
- package/src/resolve-menu-items.ts +184 -0
- package/src/routes.ts +184 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a profile log, and counting what is in it.
|
|
3
|
+
*
|
|
4
|
+
* Both profilers write NDJSON where every record carries a `kind`, and both
|
|
5
|
+
* reports then ask the same three kinds of question of it: group these records,
|
|
6
|
+
* rank these numbers, and work out how many requests were open at once. This
|
|
7
|
+
* is that, once, so `summarize.mjs` and `interference-summary.mjs` cannot drift
|
|
8
|
+
* apart on the answers.
|
|
9
|
+
*
|
|
10
|
+
* Pure. The markdown these numbers end up in is `format.mjs`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Every record in an NDJSON log, in order.
|
|
15
|
+
*
|
|
16
|
+
* A process killed mid-write leaves a partial last line, and a line that does
|
|
17
|
+
* not parse is skipped rather than thrown: everything before it is still worth
|
|
18
|
+
* reading.
|
|
19
|
+
*/
|
|
20
|
+
export function parseNdjson(text) {
|
|
21
|
+
const records = [];
|
|
22
|
+
|
|
23
|
+
for (const line of text.split('\n')) {
|
|
24
|
+
const trimmed = line.trim();
|
|
25
|
+
|
|
26
|
+
if (!trimmed) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
records.push(JSON.parse(trimmed));
|
|
32
|
+
} catch {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return records;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function sum(values) {
|
|
41
|
+
return values.reduce(
|
|
42
|
+
(total, value) => total + (typeof value === 'number' ? value : 0),
|
|
43
|
+
0,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function max(values) {
|
|
48
|
+
return values.reduce(
|
|
49
|
+
(highest, value) =>
|
|
50
|
+
typeof value === 'number' && value > highest ? value : highest,
|
|
51
|
+
0,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function group(items, keyOf) {
|
|
56
|
+
const groups = new Map();
|
|
57
|
+
|
|
58
|
+
for (const item of items) {
|
|
59
|
+
const key = keyOf(item);
|
|
60
|
+
const existing = groups.get(key);
|
|
61
|
+
|
|
62
|
+
if (existing) {
|
|
63
|
+
existing.members.push(item);
|
|
64
|
+
} else {
|
|
65
|
+
groups.set(key, { key, members: [item] });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return groups;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Nearest-rank percentile: the smallest sample at or above the requested
|
|
74
|
+
* share of the distribution, with no interpolation between two samples.
|
|
75
|
+
*
|
|
76
|
+
* A poll run produces tens of samples, not thousands, so an interpolated p95
|
|
77
|
+
* would be a number that was never measured. This returns one that was.
|
|
78
|
+
*/
|
|
79
|
+
export function percentile(values, share) {
|
|
80
|
+
const sorted = values
|
|
81
|
+
.filter((value) => typeof value === 'number')
|
|
82
|
+
.sort((a, b) => a - b);
|
|
83
|
+
|
|
84
|
+
if (!sorted.length) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const rank = Math.ceil(share * sorted.length);
|
|
89
|
+
|
|
90
|
+
return sorted[Math.min(Math.max(rank, 1), sorted.length) - 1];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A set of requests as a sorted stream of open and close events, ready to
|
|
95
|
+
* sweep for how many were in flight at once.
|
|
96
|
+
*
|
|
97
|
+
* `from` and `to` clamp the events to a span, so a caller asking about one
|
|
98
|
+
* moment of a build gets the level inside it rather than the level overall. A
|
|
99
|
+
* request that closed before the span opened is dropped entirely.
|
|
100
|
+
*/
|
|
101
|
+
export function flightEvents(
|
|
102
|
+
requests,
|
|
103
|
+
{ from = -Infinity, to = Infinity } = {},
|
|
104
|
+
) {
|
|
105
|
+
const events = [];
|
|
106
|
+
|
|
107
|
+
for (const request of requests) {
|
|
108
|
+
const startedAt = request.startedAt;
|
|
109
|
+
const endedAt = request.endedAt ?? request.startedAt;
|
|
110
|
+
|
|
111
|
+
if (typeof startedAt !== 'number' || endedAt < from || startedAt > to) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
events.push({ at: Math.max(startedAt, from), delta: 1 });
|
|
116
|
+
events.push({ at: Math.min(endedAt, to), delta: -1 });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// At an identical timestamp, close before opening: two requests that merely
|
|
120
|
+
// touch at millisecond resolution were not in flight together.
|
|
121
|
+
events.sort((a, b) => a.at - b.at || a.delta - b.delta);
|
|
122
|
+
|
|
123
|
+
return events;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The most requests open at once anywhere in `[from, to]`. */
|
|
127
|
+
export function peakInFlight(requests, from, to) {
|
|
128
|
+
let level = 0;
|
|
129
|
+
let peak = 0;
|
|
130
|
+
|
|
131
|
+
for (const event of flightEvents(requests, { from, to })) {
|
|
132
|
+
level += event.delta;
|
|
133
|
+
peak = Math.max(peak, level);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return peak;
|
|
137
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Renders a fetch log as Markdown.
|
|
4
|
+
*
|
|
5
|
+
* cms-profile-report # newest log in .astro/profile
|
|
6
|
+
* cms-profile-report path/to/log.ndjson
|
|
7
|
+
* cms-profile-report log.ndjson --out report.md
|
|
8
|
+
*
|
|
9
|
+
* `pnpm build:profile` calls `writeReport` directly once its build finishes;
|
|
10
|
+
* this entry point exists to re-render a log without rebuilding, and to render
|
|
11
|
+
* one collected somewhere else.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
existsSync,
|
|
16
|
+
readdirSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
realpathSync,
|
|
19
|
+
statSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
} from 'node:fs';
|
|
22
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { parseLog, renderMarkdown, summarize } from './summarize.mjs';
|
|
25
|
+
|
|
26
|
+
export const LOG_DIR = join('.astro', 'profile');
|
|
27
|
+
|
|
28
|
+
/** Renders `logPath` and writes the Markdown, returning where it landed. */
|
|
29
|
+
export function writeReport(logPath, outPath = defaultOutPath(logPath)) {
|
|
30
|
+
const { requests, meta } = parseLog(readFileSync(logPath, 'utf-8'));
|
|
31
|
+
|
|
32
|
+
const markdown = renderMarkdown(summarize({ requests, meta }), {
|
|
33
|
+
logPath,
|
|
34
|
+
generatedAt: new Date().toISOString(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
writeFileSync(outPath, markdown);
|
|
38
|
+
|
|
39
|
+
return outPath;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The most recently written `.ndjson` in `dir`, or null if there is none. */
|
|
43
|
+
export function newestLog(dir = LOG_DIR) {
|
|
44
|
+
if (!existsSync(dir)) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const logs = readdirSync(dir)
|
|
49
|
+
.filter((name) => name.endsWith('.ndjson'))
|
|
50
|
+
.map((name) => join(dir, name))
|
|
51
|
+
.map((path) => ({ path, at: statSync(path).mtimeMs }))
|
|
52
|
+
.sort((a, b) => b.at - a.at);
|
|
53
|
+
|
|
54
|
+
return logs.length ? logs[0].path : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function defaultOutPath(logPath) {
|
|
58
|
+
return join(
|
|
59
|
+
dirname(logPath),
|
|
60
|
+
`${basename(logPath).replace(/\.ndjson$/, '')}.md`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function main(argv) {
|
|
65
|
+
const out = argv.indexOf('--out');
|
|
66
|
+
const outPath = out === -1 ? undefined : argv[out + 1];
|
|
67
|
+
const positional = (out === -1 ? argv : argv.slice(0, out)).filter(
|
|
68
|
+
(argument) => !argument.startsWith('-'),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const logPath = positional[0] ?? newestLog();
|
|
72
|
+
|
|
73
|
+
if (!logPath) {
|
|
74
|
+
console.error(
|
|
75
|
+
`No fetch log found in ${LOG_DIR}. Run \`pnpm build:profile\` first.`,
|
|
76
|
+
);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log(writeReport(logPath, outPath));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Through a package bin, `argv[1]` is the path via the site's `node_modules`
|
|
84
|
+
// symlink while `import.meta.url` is the real one, so compare real paths.
|
|
85
|
+
if (
|
|
86
|
+
process.argv[1] &&
|
|
87
|
+
realpathSync(resolve(process.argv[1])) === fileURLToPath(import.meta.url)
|
|
88
|
+
) {
|
|
89
|
+
main(process.argv.slice(2));
|
|
90
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The site's environment, resolved by the site's own Vite, in the directory the
|
|
7
|
+
* command was run from. The profilers run inside a site but ship in this
|
|
8
|
+
* package, and the package does not depend on Vite: reading the env files any
|
|
9
|
+
* other way could resolve a different value than the build about to be profiled.
|
|
10
|
+
*/
|
|
11
|
+
export async function loadSiteEnv(mode) {
|
|
12
|
+
const require = createRequire(join(process.cwd(), 'package.json'));
|
|
13
|
+
const { loadEnv } = await import(pathToFileURL(require.resolve('vite')).href);
|
|
14
|
+
|
|
15
|
+
return loadEnv(mode, process.cwd(), '');
|
|
16
|
+
}
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns a fetch log into a report.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions over the NDJSON written by `fetch-log.mjs`: no file system, no
|
|
5
|
+
* clock, no argv, so the whole thing can be checked against a fixture. The CLI
|
|
6
|
+
* that reads and writes files is `report.mjs`.
|
|
7
|
+
*
|
|
8
|
+
* Three questions shape what gets computed, in the order they are worth asking:
|
|
9
|
+
*
|
|
10
|
+
* 1. How much does a build ask for at all — requests, bytes, time.
|
|
11
|
+
* 2. How much of it arrives at once, which is what the CMS feels.
|
|
12
|
+
* 3. How much of it was already asked for, which is what a missing cache looks
|
|
13
|
+
* like from the outside.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
bar,
|
|
18
|
+
bullets,
|
|
19
|
+
count,
|
|
20
|
+
duration,
|
|
21
|
+
escapePipes,
|
|
22
|
+
offsetSeconds,
|
|
23
|
+
share,
|
|
24
|
+
size,
|
|
25
|
+
table,
|
|
26
|
+
} from './format.mjs';
|
|
27
|
+
import { flightEvents, group, max, parseNdjson, sum } from './measure.mjs';
|
|
28
|
+
|
|
29
|
+
const TIMELINE_BUCKETS = 24;
|
|
30
|
+
|
|
31
|
+
/** How many of the build's costliest CMS requests to name individually. */
|
|
32
|
+
const SLOWEST_REQUESTS = 10;
|
|
33
|
+
|
|
34
|
+
export function parseLog(text) {
|
|
35
|
+
const requests = [];
|
|
36
|
+
const meta = [];
|
|
37
|
+
|
|
38
|
+
for (const record of parseNdjson(text)) {
|
|
39
|
+
if (record.kind === 'request') {
|
|
40
|
+
requests.push(record);
|
|
41
|
+
} else if (record.kind === 'meta') {
|
|
42
|
+
meta.push(record);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { requests, meta };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function summarize({ requests, meta }) {
|
|
50
|
+
const cms = requests.filter((request) => request.cms);
|
|
51
|
+
const other = requests.filter((request) => !request.cms);
|
|
52
|
+
const window = timeWindow(requests, meta);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
cmsOrigin: meta.find((record) => record.cmsOrigin)?.cmsOrigin ?? null,
|
|
56
|
+
window,
|
|
57
|
+
totals: {
|
|
58
|
+
requests: requests.length,
|
|
59
|
+
cmsRequests: cms.length,
|
|
60
|
+
otherRequests: other.length,
|
|
61
|
+
wallMs: window.endedAt - window.startedAt,
|
|
62
|
+
requestMs: sum(requests.map((request) => request.durationMs)),
|
|
63
|
+
cmsRequestMs: sum(cms.map((request) => request.durationMs)),
|
|
64
|
+
bytes: sum(requests.map((request) => request.bytes)),
|
|
65
|
+
cmsBytes: sum(cms.map((request) => request.bytes)),
|
|
66
|
+
failed: requests.filter((request) => !isOk(request)).length,
|
|
67
|
+
},
|
|
68
|
+
collections: collectionRows(cms),
|
|
69
|
+
concurrency: concurrency(requests, window),
|
|
70
|
+
// Fonts and remote images arrive in one burst that dwarfs anything the
|
|
71
|
+
// CMS is asked, so the overall peak says nothing about the CMS. This is
|
|
72
|
+
// the number the CMS feels.
|
|
73
|
+
cmsConcurrency: concurrency(cms, window),
|
|
74
|
+
repeated: repeatedRows(cms),
|
|
75
|
+
slowest: slowestRows(cms, window),
|
|
76
|
+
hosts: hostRows(other),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The CMS requests that cost the most, each with the CMS's own id for it.
|
|
82
|
+
*
|
|
83
|
+
* A collection row says where a build's time went; this says which single
|
|
84
|
+
* request to go and read. `profileId` is the `x-cms-profile-id` a deployment
|
|
85
|
+
* running with `CMS_PROFILE=1` returns, and it is the whole point of the row:
|
|
86
|
+
* without it a slow request is a duration, and with it the CMS can be asked
|
|
87
|
+
* what the time was spent on.
|
|
88
|
+
*/
|
|
89
|
+
function slowestRows(requests, window) {
|
|
90
|
+
return [...requests]
|
|
91
|
+
.filter((request) => typeof request.durationMs === 'number')
|
|
92
|
+
.sort((a, b) => b.durationMs - a.durationMs)
|
|
93
|
+
.slice(0, SLOWEST_REQUESTS)
|
|
94
|
+
.map((request) => ({
|
|
95
|
+
atMs: (request.startedAt ?? window.startedAt) - window.startedAt,
|
|
96
|
+
collection: request.collection ?? request.path ?? '-',
|
|
97
|
+
locale: request.locale ?? '-',
|
|
98
|
+
depth: request.depth ?? '-',
|
|
99
|
+
page: request.page ?? '-',
|
|
100
|
+
durationMs: request.durationMs,
|
|
101
|
+
status: request.status ?? null,
|
|
102
|
+
profileId: request.profileId ?? null,
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* CMS requests grouped the way the CMS sees them: which collection, in which
|
|
108
|
+
* locale, at which depth. Ordered by the time they cost, because that is the
|
|
109
|
+
* column a reader is looking for.
|
|
110
|
+
*/
|
|
111
|
+
function collectionRows(requests) {
|
|
112
|
+
const rows = group(requests, (request) =>
|
|
113
|
+
[
|
|
114
|
+
request.collection ?? request.path ?? '-',
|
|
115
|
+
request.locale ?? '-',
|
|
116
|
+
request.depth ?? '-',
|
|
117
|
+
].join(' '),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
return [...rows.values()]
|
|
121
|
+
.map(({ key, members }) => {
|
|
122
|
+
const [collection, locale, depth] = key.split(' ');
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
collection,
|
|
126
|
+
locale,
|
|
127
|
+
depth,
|
|
128
|
+
count: members.length,
|
|
129
|
+
totalMs: sum(members.map((request) => request.durationMs)),
|
|
130
|
+
maxMs: max(members.map((request) => request.durationMs)),
|
|
131
|
+
bytes: sum(members.map((request) => request.bytes)),
|
|
132
|
+
};
|
|
133
|
+
})
|
|
134
|
+
.sort((a, b) => b.totalMs - a.totalMs || b.count - a.count);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The peak number of requests in flight, and where in the build the overlap
|
|
139
|
+
* sits. Wall time is cut into fixed buckets and each one reports the highest
|
|
140
|
+
* level reached inside it, so a single burst stays visible instead of being
|
|
141
|
+
* averaged away.
|
|
142
|
+
*/
|
|
143
|
+
function concurrency(requests, window) {
|
|
144
|
+
const events = flightEvents(requests);
|
|
145
|
+
|
|
146
|
+
const wallMs = Math.max(window.endedAt - window.startedAt, 1);
|
|
147
|
+
const bucketCount = Math.min(TIMELINE_BUCKETS, Math.max(wallMs, 1));
|
|
148
|
+
const width = wallMs / bucketCount;
|
|
149
|
+
|
|
150
|
+
let level = 0;
|
|
151
|
+
let peak = 0;
|
|
152
|
+
let cursor = 0;
|
|
153
|
+
const timeline = [];
|
|
154
|
+
|
|
155
|
+
for (let bucket = 0; bucket < bucketCount; bucket += 1) {
|
|
156
|
+
const startMs = bucket * width;
|
|
157
|
+
const endMs = bucket === bucketCount - 1 ? wallMs : startMs + width;
|
|
158
|
+
let bucketPeak = level;
|
|
159
|
+
|
|
160
|
+
while (
|
|
161
|
+
cursor < events.length &&
|
|
162
|
+
events[cursor].at - window.startedAt < endMs
|
|
163
|
+
) {
|
|
164
|
+
level += events[cursor].delta;
|
|
165
|
+
bucketPeak = Math.max(bucketPeak, level);
|
|
166
|
+
peak = Math.max(peak, level);
|
|
167
|
+
cursor += 1;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
timeline.push({ startMs, endMs, peak: bucketPeak });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Whatever is left is an event landing exactly on the end of the window.
|
|
174
|
+
while (cursor < events.length) {
|
|
175
|
+
level += events[cursor].delta;
|
|
176
|
+
peak = Math.max(peak, level);
|
|
177
|
+
cursor += 1;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { peak, timeline };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Requests a build made more than once with the same question: same collection,
|
|
185
|
+
* same locale, same page, same `where`. One of these per generated page is the
|
|
186
|
+
* signature of a fetch that should have been cached and was not.
|
|
187
|
+
*/
|
|
188
|
+
function repeatedRows(requests) {
|
|
189
|
+
const rows = group(requests, (request) =>
|
|
190
|
+
[
|
|
191
|
+
request.collection ?? request.path ?? '-',
|
|
192
|
+
request.locale ?? '-',
|
|
193
|
+
request.page ?? '-',
|
|
194
|
+
request.whereHash ?? request.where ?? '-',
|
|
195
|
+
].join(' '),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
return [...rows.values()]
|
|
199
|
+
.filter(({ members }) => members.length > 1)
|
|
200
|
+
.map(({ key, members }) => {
|
|
201
|
+
const [collection, locale, page] = key.split(' ');
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
collection,
|
|
205
|
+
locale,
|
|
206
|
+
page,
|
|
207
|
+
where: members[0].where ?? null,
|
|
208
|
+
count: members.length,
|
|
209
|
+
totalMs: sum(members.map((request) => request.durationMs)),
|
|
210
|
+
};
|
|
211
|
+
})
|
|
212
|
+
.sort((a, b) => b.count - a.count || b.totalMs - a.totalMs);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Everything that was not the CMS - fonts, media, image sources - by host. */
|
|
216
|
+
function hostRows(requests) {
|
|
217
|
+
const rows = group(requests, (request) => request.host ?? '-');
|
|
218
|
+
|
|
219
|
+
return [...rows.values()]
|
|
220
|
+
.map(({ key, members }) => ({
|
|
221
|
+
host: key,
|
|
222
|
+
count: members.length,
|
|
223
|
+
totalMs: sum(members.map((request) => request.durationMs)),
|
|
224
|
+
bytes: sum(members.map((request) => request.bytes)),
|
|
225
|
+
}))
|
|
226
|
+
.sort((a, b) => b.count - a.count);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The span the report covers. Meta records bracket the process, but a log
|
|
231
|
+
* missing them - a build killed before exit, or a hand-run preload - still has
|
|
232
|
+
* request timestamps to fall back on.
|
|
233
|
+
*/
|
|
234
|
+
function timeWindow(requests, meta) {
|
|
235
|
+
const starts = [
|
|
236
|
+
...meta.map((record) => record.startedAt),
|
|
237
|
+
...requests.map((request) => request.startedAt),
|
|
238
|
+
].filter((value) => typeof value === 'number');
|
|
239
|
+
|
|
240
|
+
const ends = [
|
|
241
|
+
...meta.map((record) => record.endedAt),
|
|
242
|
+
...requests.map((request) => request.endedAt ?? request.startedAt),
|
|
243
|
+
].filter((value) => typeof value === 'number');
|
|
244
|
+
|
|
245
|
+
const startedAt = starts.length ? Math.min(...starts) : 0;
|
|
246
|
+
const endedAt = ends.length ? Math.max(...ends) : startedAt;
|
|
247
|
+
|
|
248
|
+
return { startedAt, endedAt };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** A request the CMS answered, as opposed to one it refused or never saw. */
|
|
252
|
+
function isOk(request) {
|
|
253
|
+
return (
|
|
254
|
+
typeof request.status === 'number' &&
|
|
255
|
+
request.status >= 200 &&
|
|
256
|
+
request.status < 400
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Why a log holds no CMS request — which is two different facts.
|
|
262
|
+
*
|
|
263
|
+
* A request is only the CMS's because its origin matched the one the profiler
|
|
264
|
+
* was given, so an origin that never resolved moves every request the build
|
|
265
|
+
* made into "other hosts" and leaves this section empty. That reads exactly
|
|
266
|
+
* like a build that never called the CMS, and is a misconfigured profiler
|
|
267
|
+
* rather than a finding, so it has to be said in the report and not only in
|
|
268
|
+
* the terminal the run scrolled past.
|
|
269
|
+
*/
|
|
270
|
+
function noCmsRequests(summary, { brief = false } = {}) {
|
|
271
|
+
if (summary.cmsOrigin) {
|
|
272
|
+
return 'No CMS requests in this log.';
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return brief
|
|
276
|
+
? 'No CMS origin was configured, so nothing could be recognised as the CMS.'
|
|
277
|
+
: 'No CMS origin was configured, so nothing could be recognised as the CMS and every request is counted under Other hosts. Set `API_BASE_URL`, or `PROFILE_CMS_ORIGIN` to profile against another origin.';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function renderMarkdown(summary, { logPath, generatedAt } = {}) {
|
|
281
|
+
const { totals, concurrency: overlap } = summary;
|
|
282
|
+
|
|
283
|
+
const lines = [
|
|
284
|
+
'# Build fetch profile',
|
|
285
|
+
'',
|
|
286
|
+
...bullets([
|
|
287
|
+
['Log', logPath ? `\`${logPath}\`` : null],
|
|
288
|
+
['Generated', generatedAt ?? null],
|
|
289
|
+
['CMS origin', summary.cmsOrigin ? `\`${summary.cmsOrigin}\`` : null],
|
|
290
|
+
[
|
|
291
|
+
'Build started',
|
|
292
|
+
summary.window.startedAt
|
|
293
|
+
? new Date(summary.window.startedAt).toISOString()
|
|
294
|
+
: null,
|
|
295
|
+
],
|
|
296
|
+
]),
|
|
297
|
+
'',
|
|
298
|
+
'## Totals',
|
|
299
|
+
'',
|
|
300
|
+
...table(
|
|
301
|
+
['Metric', 'Value'],
|
|
302
|
+
[
|
|
303
|
+
['Build wall time', duration(totals.wallMs)],
|
|
304
|
+
['Requests', count(totals.requests)],
|
|
305
|
+
['To the CMS', count(totals.cmsRequests)],
|
|
306
|
+
['Elsewhere', count(totals.otherRequests)],
|
|
307
|
+
['Failed or errored', count(totals.failed)],
|
|
308
|
+
[
|
|
309
|
+
'Summed request time',
|
|
310
|
+
`${duration(totals.requestMs)} (${share(totals.requestMs, totals.wallMs)} of wall time)`,
|
|
311
|
+
],
|
|
312
|
+
['Summed CMS request time', duration(totals.cmsRequestMs)],
|
|
313
|
+
['Peak requests in flight', count(overlap.peak)],
|
|
314
|
+
['Peak CMS requests in flight', count(summary.cmsConcurrency.peak)],
|
|
315
|
+
['Response bytes', size(totals.bytes)],
|
|
316
|
+
['Response bytes from the CMS', size(totals.cmsBytes)],
|
|
317
|
+
],
|
|
318
|
+
),
|
|
319
|
+
'',
|
|
320
|
+
'## CMS requests by collection',
|
|
321
|
+
'',
|
|
322
|
+
...(summary.collections.length
|
|
323
|
+
? table(
|
|
324
|
+
['Collection', 'Locale', 'Depth', 'Requests', 'Total', 'Max', 'Size'],
|
|
325
|
+
summary.collections.map((row) => [
|
|
326
|
+
`\`${row.collection}\``,
|
|
327
|
+
row.locale,
|
|
328
|
+
row.depth,
|
|
329
|
+
count(row.count),
|
|
330
|
+
duration(row.totalMs),
|
|
331
|
+
duration(row.maxMs),
|
|
332
|
+
size(row.bytes),
|
|
333
|
+
]),
|
|
334
|
+
)
|
|
335
|
+
: [noCmsRequests(summary)]),
|
|
336
|
+
'',
|
|
337
|
+
'## Slowest CMS requests',
|
|
338
|
+
'',
|
|
339
|
+
...(summary.slowest.length
|
|
340
|
+
? [
|
|
341
|
+
...table(
|
|
342
|
+
[
|
|
343
|
+
'At',
|
|
344
|
+
'Collection',
|
|
345
|
+
'Locale',
|
|
346
|
+
'Depth',
|
|
347
|
+
'Page',
|
|
348
|
+
'Duration',
|
|
349
|
+
'CMS profile id',
|
|
350
|
+
],
|
|
351
|
+
summary.slowest.map((row) => [
|
|
352
|
+
offsetSeconds(row.atMs),
|
|
353
|
+
`\`${row.collection}\``,
|
|
354
|
+
row.locale,
|
|
355
|
+
row.depth,
|
|
356
|
+
row.page,
|
|
357
|
+
duration(row.durationMs),
|
|
358
|
+
row.profileId ? `\`${row.profileId}\`` : '-',
|
|
359
|
+
]),
|
|
360
|
+
),
|
|
361
|
+
'',
|
|
362
|
+
...(summary.slowest.some((row) => row.profileId)
|
|
363
|
+
? [
|
|
364
|
+
'Each id is one `cms.profile.request` line on the CMS, so a row here can be opened into the commands and hooks that produced it.',
|
|
365
|
+
]
|
|
366
|
+
: [
|
|
367
|
+
'No response carried `x-cms-profile-id`, so these are durations with nothing behind them. Run the target with `CMS_PROFILE=1` to join them to the CMS’s own lines.',
|
|
368
|
+
]),
|
|
369
|
+
]
|
|
370
|
+
: [noCmsRequests(summary, { brief: true })]),
|
|
371
|
+
'',
|
|
372
|
+
'## Concurrency',
|
|
373
|
+
'',
|
|
374
|
+
`Peak: **${count(overlap.peak)}** request${overlap.peak === 1 ? '' : 's'} in flight at once, and **${count(summary.cmsConcurrency.peak)}** counting only the CMS.`,
|
|
375
|
+
'',
|
|
376
|
+
...table(
|
|
377
|
+
['Window', 'Peak in flight', 'Peak at the CMS', 'CMS overlap'],
|
|
378
|
+
overlap.timeline.map((bucket, index) => {
|
|
379
|
+
// Scaled to the CMS peak, not the overall one: one burst of fonts
|
|
380
|
+
// would otherwise flatten every bar that matters into nothing.
|
|
381
|
+
const cmsPeak = summary.cmsConcurrency.timeline[index]?.peak ?? 0;
|
|
382
|
+
|
|
383
|
+
return [
|
|
384
|
+
`${offsetSeconds(bucket.startMs)} to ${offsetSeconds(bucket.endMs)}`,
|
|
385
|
+
count(bucket.peak),
|
|
386
|
+
count(cmsPeak),
|
|
387
|
+
bar(cmsPeak, summary.cmsConcurrency.peak),
|
|
388
|
+
];
|
|
389
|
+
}),
|
|
390
|
+
),
|
|
391
|
+
'',
|
|
392
|
+
'## Repeated requests',
|
|
393
|
+
'',
|
|
394
|
+
...(summary.repeated.length
|
|
395
|
+
? [
|
|
396
|
+
'Same collection, locale, page and `where`, asked more than once.',
|
|
397
|
+
'',
|
|
398
|
+
...table(
|
|
399
|
+
['Collection', 'Locale', 'Page', 'Times', 'Total', 'Where'],
|
|
400
|
+
summary.repeated.map((row) => [
|
|
401
|
+
`\`${row.collection}\``,
|
|
402
|
+
row.locale,
|
|
403
|
+
row.page,
|
|
404
|
+
count(row.count),
|
|
405
|
+
duration(row.totalMs),
|
|
406
|
+
row.where ? `\`${escapePipes(row.where)}\`` : '-',
|
|
407
|
+
]),
|
|
408
|
+
),
|
|
409
|
+
]
|
|
410
|
+
: ['Nothing was requested twice.']),
|
|
411
|
+
'',
|
|
412
|
+
'## Other hosts',
|
|
413
|
+
'',
|
|
414
|
+
...(summary.hosts.length
|
|
415
|
+
? table(
|
|
416
|
+
['Host', 'Requests', 'Total', 'Size'],
|
|
417
|
+
summary.hosts.map((row) => [
|
|
418
|
+
`\`${row.host}\``,
|
|
419
|
+
count(row.count),
|
|
420
|
+
duration(row.totalMs),
|
|
421
|
+
size(row.bytes),
|
|
422
|
+
]),
|
|
423
|
+
)
|
|
424
|
+
: ['Every request went to the CMS.']),
|
|
425
|
+
'',
|
|
426
|
+
];
|
|
427
|
+
|
|
428
|
+
return `${lines.join('\n')}\n`;
|
|
429
|
+
}
|