Profiler
Recording
The two ways to capture a profile. Profile a page by URL, or profile a single request with a header. Plus curated JSON, a profile view on serverless through dump and upload, the production secret, sharper names with source maps, and each option.
On this page
- Profile a page
- Profile a single request
- For agents and scripts
- Serverless and edge
- In production
- Sharper names with source maps
- On the dev server
- Cost and safety
- Options
- Catch the slow one
- Always-on sampling
- In CI: budgets and the first bad commit
- Ephemeral hosts: the browser keeps the report
- The sink: the whole site from ephemeral instances
There are two ways to capture a profile. The main one lives on the dashboard at /__profiler: type a path and profile it. The other is a header that you can send from anywhere.
Profile a page
Type a path. The profiler renders it several times, one after the other, and profiles only those renders. This is the main way in. Use it when you know which page is slow. That is almost always the case.
It does one warm-up render first, without a profile. Thus the one-time cost of module loads and cache fills lands outside the measured window, and the median stays steady.
/__profiler → Profile a page → /some/slow/pageThe page renders through your real server. Thus all things it needs — a session, cookies, a load — run exactly as in production.
Profile a single request
Send a request with the header x-profile: <secret>, and get a profile of exactly that request. The report URL comes back in the x-profile-report response header.
curl -sD - 'https://example.com/slow/page' -H 'x-profile: your-secret' | grep x-profile-reportThis starts a fresh CPU profile for the one request. Thus it adds the start-up cost of the profiler to the latency of that request. Use it to catch one specific slow request, not for steady measurement.
For agents and scripts
Each report is also available as curated JSON. This is not the raw V8 profile. It is the analyzed result: self and total per component, network attribution, memory, GC, and the same findings that the human report shows.
# an existing report
GET /__profiler/report/<id>.json
# profile a page and get the JSON back in one request (no redirect, no HTML)
GET /__profiler/page?p=/slow/page&runs=6&format=json&key=<secret>Accept: application/json on /record or /page does the same. The payload describes itself (schema, version, and units). Thus an agent can read it directly:
{
"schema": "ogygia-profiler-report",
"summary": { "window_ms": 699, "busy_pct": 100, "verdict": "compute-bound" },
"findings": [{ "severity": "warn", "code": "sequential-network", "message": "…" }],
"components": [{ "name": "PrimeSieve", "file": "src/…/PrimeSieve.svelte", "self_ms": 296, "total_ms": 296 }],
"network": { "sequential_ms": 0, "hosts": [], "calls": [] },
"memory": { "growth_mb": 195, "gc": { "max_ms": 2.2 }, "allocators": [] }
// budget, hot_functions, files, requests, links…
}Serverless and edge
The live dashboard wants a long-lived Node server: the V8 inspector, and memory that continues between requests. Serverless Node platforms (Lambda, Amplify) can record. The inspector runs in-process. But the report lives in memory, on an instance that can be gone before you open it. True edge runtimes (Workers) have no inspector. There, nothing records beyond the always-on request log.
For the serverless case, take the report with you. Each report downloads as a self-contained JSON dump: everything necessary to render it, with no inspector to read it back:
# a one-shot page profile, straight to a dump
GET /__profiler/page?p=/slow/page&runs=6&format=dump&key=<secret>
# or an existing report
GET /__profiler/report/<id>/dumpThen open /__profiler/view anywhere — your production route, or the same app on localhost — and upload the file. The full HTML report renders from the dump alone. The renderer is a pure function of it.
In production
Set a secret. Without one, the UI is off and 404s.
OGYGIA_PROFILER_SECRET=some-long-random-stringThen open the dashboard and log in with the secret. The profiler sets a session cookie. Thus links inside a report operate for the remainder of the session, without the secret again. The match is timing-safe, and the key never rides in a URL, where a log, a cache, or a share would hold it. The profiler is also reachable programmatically over MCP, with ogygia mcp / ogygia ai.
Sharper names with source maps
Production bundles rename and inline code, so without source maps a frame points at a chunk file. With the profiler on, the server build writes hidden source maps by itself: .map files beside the server chunks, with no //# sourceMappingURL comment. Nothing to set up. The profiler reads them to map each frame back to its source file and line, with the original names, on whatever host runs the build.
- They stay on the server. A browser never loads a server chunk. The client build makes hidden maps too, but they never reach a deploy. The moment it has written its output, ogygia moves them out of it, into
.svelte-kit/ogygia-client-maps/, a folder no adapter copies. The server module below then keeps the ones for chunks with your own code in them, and the folder is removed. Nothing in the client output points at a map, and no map is served. - The browser’s traces get your lines too. A browser CPU trace from a build names minified functions in hashed chunks. The profiler maps them through those kept maps, the same way it maps a server frame, whether the host has the client files or knows only their URLs. So a slow click reads
save (SlowSave.svelte:5). - The cost is disk space. The playground’s server output grows from about 6 MB to 12 MB.
- Your own choice wins. Set
build.sourcemapyourself (falseto turn them off,truefor maps everywhere) and ogygia leaves it alone. - They survive any adapter. An adapter copies what the server code imports, not files read by path: Vercel’s leaves the
.mapfiles behind, and adapter-node re-bundles the chunks with maps of its own that point back at files it does not ship. So the build also writes the maps (and which source module sits at which chunk line) into one server module that only the profiler imports, the first time it reads a profile. It ships with every adapter, a request never loads it, and the profiler follows an adapter’s own map through it back to your source. It also carries your source text, so the checks that read a load or a component line by line work on a host with nosrc/folder. - That module stays small. It keeps the source text of your own
src/code only (not your packages’ text, not imported.jsondata) and is gzipped: a 34 MB set of maps on a large app ships as 4 MB. Reading a profile unpacks it once, into about 11 MB of memory.
The report header shows sourcemapped when it operated.
On the dev server
vite dev works too, with the same findings. Vite runs each server module as transformed code built from a string, which hides where things came from in four ways, and the profiler handles each:
- Lines. A profile’s lines are the transformed code’s. The maps back to your source live in Vite’s module graph, not in
.mapfiles, and the ogygia plugin hands them to the profiler. - Callers. A stack frame of such code has no file name, only its
sourceURL. The profiler reads that. - Call counts. Coverage names such a script
/app/x.ts, while the CPU profile saysfile:///app/x.ts, so the counts are matched by script id. - Memory. The memory sampler leaves such a script unnamed. The profiler names it from the CPU profile.
Vite’s pre-bundled dependencies (node_modules/.vite/) are filed as the package they are. That includes Svelte’s own runtime, even in a shared chunk named dev-Cl0b.js.
Which page.data keys each island reads is a build’s answer (the dev server’s seed ships whole). On the dev server the plugin runs the build’s same analysis when the profiler asks: the island’s component, the app modules it imports, and a helper it hands the page to, one level deep. So “ships all page data” and “waits for a late island” work there too. The seed the dev server sends is unchanged. Timings on the dev server are the dev server’s, so compare forecasts on a build.
Cost and safety
- Idle cost is almost nothing. The CPU is sampled only during a recording (about 1–3% then). Between recordings, there is no sampling, unless you turn on the trap or always-on sampling below. Per request the profiler reads two CPU counters and a clock and pushes one entry into a ring buffer: microseconds. A cold start pays one import of the profiler’s chunk (about 30 ms).
- Always-on work per request: wall timing, a capped in-memory request log, and — unless you set network capture off — one
AsyncLocalStoragewrap plus afetch/httppatch, to attribute outbound calls. Pass{ network: false }for the leanest path. Server-Timingheaders show internal timings to each client. Thus they are on in dev, off in production by default. Pass{ serverTiming: true }to force them on.- Recorded profiles stay in memory only (the last few, gzipped). They are never written to disk.
Options
Pass an object in place of true to the profiler option of the vite plugin:
// vite.config.ts
ogygia({
profiler: {
secret: process.env.OGYGIA_PROFILER_SECRET, // default: the OGYGIA_PROFILER_SECRET env var
path: '/__profiler', // UI base path
sampleInterval: 500, // µs between CPU samples
maxReports: 6, // profiles kept in memory (gzipped)
network: true, // patch fetch/http to attribute calls
serverTiming: undefined, // default: on in dev, off in production
heap: true, // sample heap allocations while recording
enabled: true, // master switch
trap: { over: 800 }, // catch the slow one (off by default; see below)
sample: { every: 60, window: 2000 }, // always-on sampling (off by default; see below)
clientCpu: true, // the beacon records the browser's main-thread CPU (Chromium)
},
});trap.replay names the request headers the trap keeps with a caught request so a replay carries the same inputs (['accept-language', 'x-tenant']). None by default, and never cookies unless you name them.
Catch the slow one
A page you can profile is a page you already suspect. The p99 request you cannot reproduce needs the profiler to be watching when it happens. With trap: { over: 800 } a coarse sampler runs in rolling windows (5 s each, 10 ms sampling interval) and keeps the window only when a request in it took longer than over ms. The report is labelled caught on the dashboard, its timeline is that request’s, and the finding that names the step still works.
Built to stay cheap: the coarse interval, no per-request context (the request is matched to the window by time, so no AsyncLocalStorage runs), no stack capture on calls, no heap sampling. Expect about 1 to 3 percent CPU while it watches. It stops after keep reports (default 3) and never runs on a serverless host, where an instance lives for one request anyway. Options: over (ms, required), interval (ms, default 10), window (ms, default 5000), keep (default 3).
Always-on sampling
sample: { every: 60, window: 2000 } takes one 2 s coarse sample every minute and folds it into a rolling hot functions table on the dashboard, over the last hour of windows. Accuracy from volume: a function that is hot across sixty windows is hot in real traffic, whatever a single recording of a single page shows. Same cost profile as the trap (they share the recorder and take turns). Off on serverless.
Serverless hosts (Amplify, Lambda). Those hosts hand console variables to the build only.
The running function does not see them. So pass the secret through the vite config, as above,
and keep the option unconditional. A guard like profiler: env.SECRET && { secret } turns the
whole profiler off when the variable is missing at build time. Then /__profiler falls through
to your own routing, typically an i18n redirect to the home page, which looks like a broken
profiler. A configured profiler with an empty secret answers /__profiler with a plain
“installed but disabled” 404 instead, and the build prints a warning, so the two are easy to
tell apart.
In CI: budgets and the first bad commit
ogygia perf renders a list of pages under the profiler on a running server (a local preview in the workflow, or a deployed preview), writes a snapshot, and judges it against a baseline under a budget. It exits 1 when the budget fails and writes the PR comment as markdown.
# the baseline: the same command on main, its snapshot kept as an artifact or committed
npx ogygia perf --url http://127.0.0.1:4173 --pages /,/products,/account --runs 5 \
--key "$OGYGIA_PROFILER_SECRET" --out perf-baseline.json
# the PR: measure, diff, comment, fail
npx ogygia perf --url http://127.0.0.1:4173 --pages /,/products,/account --runs 5 \
--key "$OGYGIA_PROFILER_SECRET" --baseline perf-baseline.json --budget perf-budget.json \
--out perf-snapshot.json --md perf-comment.md --label "$GITHUB_SHA"The snapshot keeps per page: the median render and every run, CPU busy, waiting, the cold render’s extra, the seed and island props in KB, the islands’ JS in KB (unique modules counted once), the top components by self time, the hot functions, the paths to fix, and the finding codes at warn level. The comment shows one table per page (base, PR, delta, budget), the three biggest movers with their file, and the findings that appeared or went away.
The budget is a JSON file, every key optional, with per-page overrides:
{
"render_pct": 8, "render_floor_ms": 5,
"busy_pct": 15, "component_pct": 25,
"seed_kb": 20, "seed_max_kb": 300,
"props_kb": 30, "islands_js_kb": 30, "islands_js_max_kb": 250,
"cold_extra_ms": 100,
"fail_on": ["seed-whole", "never-hydrated"],
"pages": { "/": { "render_max_ms": 120 } }
}Relative budgets have a floor (a 2 ms page cannot fail on 1 ms of noise); the _max_ keys are absolute caps, so a page that was always over one fails even with no growth, and a page that has no baseline yet is judged by the caps alone. fail_on fails the build when a named finding appears. Run the profiler with runs: 5 or more in CI: the median of five renders on a shared runner is stable to a few percent, one render is not.
The first bad commit. When a page got slower somewhere in the last N commits, ogygia bisect builds and profiles commits between a good one and a bad one in a binary search, each in its own git worktree, and names the culprit:
npx ogygia bisect --good v1.4.0 --bad HEAD --page /products \
--build "pnpm install --frozen-lockfile && pnpm build" --start "node build" \
--url http://127.0.0.1:3000 --key "$OGYGIA_PROFILER_SECRET" --runs 3It measures the two ends first and sets the bar halfway between them (--margin 0.5), so noise under the bar does not mislead the search. A commit that fails to build is skipped and the search steps around it. Ten commits is four builds; a hundred is seven.
Ephemeral hosts: the browser keeps the report
A serverless instance lives for seconds and forgets everything it measured. The profiler is built for that: the server captures inside one request and hands the result over, and the browser is the database.
- Profile a page answers the run page with the report’s dump, and the browser stores it (IndexedDB, on the app’s origin) before opening the report. A report page the server no longer holds renders from the browser’s store. The dashboard lists what this browser kept, and two kept reports of the same page compare in the browser.
- The beacon writes each visit (navigation timing, resources, paints, islands, shifts, the islands’ markup before and after hydration) into the same store, so the one-clock timeline, the heatmap and DOM time travel work whether or not the instance that rendered the report still exists.
- Every report page stores itself on idle when it was opened any other way (a header profile’s link, a trap catch), so a restart of a long-lived server loses nothing you have looked at.
Nothing is shared between browsers: a report is kept by the browser that recorded or opened it. Export the .ogp or a share link to move one.
The sink: the whole site from ephemeral instances
The request cloud and the layer cake need rows from many instances over a long time. Give each instance a place to post them:
profiler: {
sink: { url: 'https://…/profiler-rows', key: process.env.PROFILER_SINK_KEY, every: 10_000 },
serverTiming: true // also lets an upstream running this profiler answer nested traces
}Each instance posts NDJSON rows: one small row per request (path, route, ms, CPU, waiting, status), one summary per always-on sampler window (the hot functions), and a caught request’s dump. key goes as a bearer token (default: the profiler secret). A long-lived host posts every every ms; a serverless one posts after each request, best effort. Serve the collected rows from any URL the browser can read (a bucket, an endpoint) and paste it into /__profiler/site.