@ogygia/devtools 0.0.0-stage → 0.11.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/LICENSE +21 -0
- package/README.md +25 -2
- package/dist/client.d.ts +3 -0
- package/dist/client.js +24 -0
- package/dist/devtools/BoundaryOverlay.svelte +396 -0
- package/dist/devtools/BoundaryOverlay.svelte.d.ts +19 -0
- package/dist/devtools/Devtools.svelte +663 -0
- package/dist/devtools/Devtools.svelte.d.ts +15 -0
- package/dist/devtools/HubTab.svelte +206 -0
- package/dist/devtools/HubTab.svelte.d.ts +11 -0
- package/dist/devtools/HydrationTab.svelte +237 -0
- package/dist/devtools/HydrationTab.svelte.d.ts +13 -0
- package/dist/devtools/IslandDetail.svelte +615 -0
- package/dist/devtools/IslandDetail.svelte.d.ts +15 -0
- package/dist/devtools/LedgerTab.svelte +287 -0
- package/dist/devtools/LedgerTab.svelte.d.ts +11 -0
- package/dist/devtools/LensTab.svelte +290 -0
- package/dist/devtools/LensTab.svelte.d.ts +19 -0
- package/dist/devtools/NavTab.svelte +229 -0
- package/dist/devtools/NavTab.svelte.d.ts +11 -0
- package/dist/devtools/PageTab.svelte +1122 -0
- package/dist/devtools/PageTab.svelte.d.ts +15 -0
- package/dist/devtools/ProfilerTab.svelte +755 -0
- package/dist/devtools/ProfilerTab.svelte.d.ts +15 -0
- package/dist/devtools/PropsTree.svelte +155 -0
- package/dist/devtools/PropsTree.svelte.d.ts +15 -0
- package/dist/devtools/SessionTab.svelte +796 -0
- package/dist/devtools/SessionTab.svelte.d.ts +11 -0
- package/dist/devtools/TimelineTab.svelte +267 -0
- package/dist/devtools/TimelineTab.svelte.d.ts +11 -0
- package/dist/devtools/WireTab.svelte +149 -0
- package/dist/devtools/WireTab.svelte.d.ts +11 -0
- package/dist/devtools/bytes.d.ts +70 -0
- package/dist/devtools/bytes.js +82 -0
- package/dist/devtools/cache-headers.d.ts +20 -0
- package/dist/devtools/cache-headers.js +81 -0
- package/dist/devtools/highlight.d.ts +7 -0
- package/dist/devtools/highlight.js +83 -0
- package/dist/devtools/hydration.d.ts +48 -0
- package/dist/devtools/hydration.js +93 -0
- package/dist/devtools/index.d.ts +3 -0
- package/dist/devtools/index.js +3 -0
- package/dist/devtools/ledger-dom.d.ts +7 -0
- package/dist/devtools/ledger-dom.js +151 -0
- package/dist/devtools/leftovers.d.ts +77 -0
- package/dist/devtools/leftovers.js +319 -0
- package/dist/devtools/names.d.ts +1 -0
- package/dist/devtools/names.js +34 -0
- package/dist/devtools/page.d.ts +96 -0
- package/dist/devtools/page.js +957 -0
- package/dist/devtools/profile-anchors.d.ts +22 -0
- package/dist/devtools/profile-anchors.js +135 -0
- package/dist/devtools/profile-store.d.ts +175 -0
- package/dist/devtools/profile-store.js +239 -0
- package/dist/devtools/regions.d.ts +77 -0
- package/dist/devtools/regions.js +132 -0
- package/dist/devtools/session.d.ts +31 -0
- package/dist/devtools/session.js +455 -0
- package/dist/devtools/since-load.d.ts +50 -0
- package/dist/devtools/since-load.js +89 -0
- package/dist/devtools/source-lines.d.ts +24 -0
- package/dist/devtools/source-lines.js +106 -0
- package/dist/devtools/styles.d.ts +52 -0
- package/dist/devtools/styles.js +248 -0
- package/dist/devtools/testing-engine.d.ts +83 -0
- package/dist/devtools/testing-engine.js +220 -0
- package/dist/devtools/ui-app.d.ts +5 -0
- package/dist/devtools/ui-app.js +69 -0
- package/dist/devtools/ui.d.ts +50 -0
- package/dist/devtools/ui.js +175 -0
- package/dist/profiler/alloc.d.ts +75 -0
- package/dist/profiler/alloc.js +76 -0
- package/dist/profiler/analyze.d.ts +437 -0
- package/dist/profiler/analyze.js +1941 -0
- package/dist/profiler/app-path.d.ts +7 -0
- package/dist/profiler/app-path.js +26 -0
- package/dist/profiler/async-io.d.ts +58 -0
- package/dist/profiler/async-io.js +187 -0
- package/dist/profiler/beacon-standalone.d.ts +4 -0
- package/dist/profiler/beacon-standalone.js +409 -0
- package/dist/profiler/browser-findings.d.ts +25 -0
- package/dist/profiler/browser-findings.js +260 -0
- package/dist/profiler/byte-strip.d.ts +58 -0
- package/dist/profiler/byte-strip.js +228 -0
- package/dist/profiler/client-files.d.ts +16 -0
- package/dist/profiler/client-files.js +60 -0
- package/dist/profiler/client-windows.d.ts +49 -0
- package/dist/profiler/client-windows.js +95 -0
- package/dist/profiler/client.d.ts +40 -0
- package/dist/profiler/client.js +82 -0
- package/dist/profiler/compare.d.ts +308 -0
- package/dist/profiler/compare.js +733 -0
- package/dist/profiler/compress.d.ts +10 -0
- package/dist/profiler/compress.js +36 -0
- package/dist/profiler/contention.d.ts +60 -0
- package/dist/profiler/contention.js +86 -0
- package/dist/profiler/crypto.d.ts +19 -0
- package/dist/profiler/crypto.js +101 -0
- package/dist/profiler/doc-diff.d.ts +47 -0
- package/dist/profiler/doc-diff.js +214 -0
- package/dist/profiler/dom-diff.d.ts +43 -0
- package/dist/profiler/dom-diff.js +121 -0
- package/dist/profiler/drill.d.ts +89 -0
- package/dist/profiler/drill.js +553 -0
- package/dist/profiler/embedded-maps.d.ts +37 -0
- package/dist/profiler/embedded-maps.js +135 -0
- package/dist/profiler/env.d.ts +6 -0
- package/dist/profiler/env.js +10 -0
- package/dist/profiler/forecast.d.ts +76 -0
- package/dist/profiler/forecast.js +225 -0
- package/dist/profiler/frames.d.ts +11 -0
- package/dist/profiler/frames.js +42 -0
- package/dist/profiler/gc.d.ts +144 -0
- package/dist/profiler/gc.js +271 -0
- package/dist/profiler/hole-slots.d.ts +31 -0
- package/dist/profiler/hole-slots.js +90 -0
- package/dist/profiler/index.d.ts +288 -0
- package/dist/profiler/index.js +4921 -0
- package/dist/profiler/insights.d.ts +96 -0
- package/dist/profiler/insights.js +239 -0
- package/dist/profiler/ledger.d.ts +121 -0
- package/dist/profiler/ledger.js +440 -0
- package/dist/profiler/lineage.d.ts +81 -0
- package/dist/profiler/lineage.js +359 -0
- package/dist/profiler/module-map.d.ts +12 -0
- package/dist/profiler/module-map.js +94 -0
- package/dist/profiler/net.d.ts +169 -0
- package/dist/profiler/net.js +586 -0
- package/dist/profiler/on-demand.d.ts +11 -0
- package/dist/profiler/on-demand.js +15 -0
- package/dist/profiler/own-requests.d.ts +9 -0
- package/dist/profiler/own-requests.js +17 -0
- package/dist/profiler/patterns.d.ts +476 -0
- package/dist/profiler/patterns.js +2203 -0
- package/dist/profiler/profiler-router.d.ts +245 -0
- package/dist/profiler/profiler-router.js +113 -0
- package/dist/profiler/refresh-stub.d.ts +2 -0
- package/dist/profiler/refresh-stub.js +37 -0
- package/dist/profiler/report.d.ts +1404 -0
- package/dist/profiler/report.js +2638 -0
- package/dist/profiler/river.d.ts +65 -0
- package/dist/profiler/river.js +298 -0
- package/dist/profiler/route-files.d.ts +22 -0
- package/dist/profiler/route-files.js +55 -0
- package/dist/profiler/route-imports.d.ts +14 -0
- package/dist/profiler/route-imports.js +83 -0
- package/dist/profiler/saving.d.ts +22 -0
- package/dist/profiler/saving.js +42 -0
- package/dist/profiler/score.d.ts +109 -0
- package/dist/profiler/score.js +261 -0
- package/dist/profiler/session-cookie.d.ts +28 -0
- package/dist/profiler/session-cookie.js +45 -0
- package/dist/profiler/sink.d.ts +81 -0
- package/dist/profiler/sink.js +124 -0
- package/dist/profiler/site-fixes.d.ts +25 -0
- package/dist/profiler/site-fixes.js +62 -0
- package/dist/profiler/source-scan.d.ts +210 -0
- package/dist/profiler/source-scan.js +1724 -0
- package/dist/profiler/span.d.ts +90 -0
- package/dist/profiler/span.js +109 -0
- package/dist/profiler/stacks.d.ts +43 -0
- package/dist/profiler/stacks.js +107 -0
- package/dist/profiler/standalone.d.ts +27 -0
- package/dist/profiler/standalone.js +98 -0
- package/dist/profiler/steps.d.ts +39 -0
- package/dist/profiler/steps.js +74 -0
- package/dist/profiler/storage/index.d.ts +87 -0
- package/dist/profiler/storage/index.js +76 -0
- package/dist/profiler/storage/postgres.d.ts +12 -0
- package/dist/profiler/storage/postgres.js +94 -0
- package/dist/profiler/storage/redis.d.ts +19 -0
- package/dist/profiler/storage/redis.js +148 -0
- package/dist/profiler/storage/sqlite.d.ts +2 -0
- package/dist/profiler/storage/sqlite.js +85 -0
- package/dist/profiler/storage/visits.d.ts +4 -0
- package/dist/profiler/storage/visits.js +6 -0
- package/dist/profiler/stream-tail.d.ts +26 -0
- package/dist/profiler/stream-tail.js +74 -0
- package/dist/profiler/timeline.d.ts +279 -0
- package/dist/profiler/timeline.js +690 -0
- package/dist/profiler/ui/AllocStrip.svelte +146 -0
- package/dist/profiler/ui/AllocStrip.svelte.d.ts +17 -0
- package/dist/profiler/ui/Awaits.svelte +363 -0
- package/dist/profiler/ui/Awaits.svelte.d.ts +16 -0
- package/dist/profiler/ui/ByteStrip.svelte +182 -0
- package/dist/profiler/ui/ByteStrip.svelte.d.ts +18 -0
- package/dist/profiler/ui/Compare.svelte +24 -0
- package/dist/profiler/ui/Compare.svelte.d.ts +16 -0
- package/dist/profiler/ui/CompareBody.svelte +429 -0
- package/dist/profiler/ui/CompareBody.svelte.d.ts +8 -0
- package/dist/profiler/ui/ComponentsTable.svelte +265 -0
- package/dist/profiler/ui/ComponentsTable.svelte.d.ts +20 -0
- package/dist/profiler/ui/Dashboard.svelte +492 -0
- package/dist/profiler/ui/Dashboard.svelte.d.ts +31 -0
- package/dist/profiler/ui/DataRiver.svelte +206 -0
- package/dist/profiler/ui/DataRiver.svelte.d.ts +13 -0
- package/dist/profiler/ui/DomTravel.svelte +207 -0
- package/dist/profiler/ui/DomTravel.svelte.d.ts +9 -0
- package/dist/profiler/ui/DownloadHtml.svelte +52 -0
- package/dist/profiler/ui/DownloadHtml.svelte.d.ts +10 -0
- package/dist/profiler/ui/Drill.svelte +276 -0
- package/dist/profiler/ui/Drill.svelte.d.ts +12 -0
- package/dist/profiler/ui/ExportButton.svelte +59 -0
- package/dist/profiler/ui/ExportButton.svelte.d.ts +8 -0
- package/dist/profiler/ui/Flame.svelte +222 -0
- package/dist/profiler/ui/Flame.svelte.d.ts +12 -0
- package/dist/profiler/ui/FrameDetails.svelte +308 -0
- package/dist/profiler/ui/FrameDetails.svelte.d.ts +16 -0
- package/dist/profiler/ui/FunctionsTable.svelte +156 -0
- package/dist/profiler/ui/FunctionsTable.svelte.d.ts +17 -0
- package/dist/profiler/ui/IslandHeatmap.svelte +151 -0
- package/dist/profiler/ui/IslandHeatmap.svelte.d.ts +16 -0
- package/dist/profiler/ui/IslandsTable.svelte +448 -0
- package/dist/profiler/ui/IslandsTable.svelte.d.ts +16 -0
- package/dist/profiler/ui/KeepLocal.svelte +46 -0
- package/dist/profiler/ui/KeepLocal.svelte.d.ts +8 -0
- package/dist/profiler/ui/Ledger.svelte +196 -0
- package/dist/profiler/ui/Ledger.svelte.d.ts +18 -0
- package/dist/profiler/ui/LocalCompare.svelte +48 -0
- package/dist/profiler/ui/LocalCompare.svelte.d.ts +8 -0
- package/dist/profiler/ui/LocalReports.svelte +68 -0
- package/dist/profiler/ui/LocalReports.svelte.d.ts +7 -0
- package/dist/profiler/ui/Login.svelte +13 -0
- package/dist/profiler/ui/Login.svelte.d.ts +12 -0
- package/dist/profiler/ui/LoginForm.svelte +42 -0
- package/dist/profiler/ui/LoginForm.svelte.d.ts +7 -0
- package/dist/profiler/ui/Message.svelte +10 -0
- package/dist/profiler/ui/Message.svelte.d.ts +12 -0
- package/dist/profiler/ui/OnThisPage.svelte +137 -0
- package/dist/profiler/ui/OnThisPage.svelte.d.ts +3 -0
- package/dist/profiler/ui/OneClock.svelte +297 -0
- package/dist/profiler/ui/OneClock.svelte.d.ts +27 -0
- package/dist/profiler/ui/Panel.svelte +10 -0
- package/dist/profiler/ui/Panel.svelte.d.ts +16 -0
- package/dist/profiler/ui/Paths.svelte +269 -0
- package/dist/profiler/ui/Paths.svelte.d.ts +15 -0
- package/dist/profiler/ui/Patterns.svelte +364 -0
- package/dist/profiler/ui/Patterns.svelte.d.ts +15 -0
- package/dist/profiler/ui/PermalinkGate.svelte +143 -0
- package/dist/profiler/ui/PermalinkGate.svelte.d.ts +10 -0
- package/dist/profiler/ui/RenderSteps.svelte +218 -0
- package/dist/profiler/ui/RenderSteps.svelte.d.ts +13 -0
- package/dist/profiler/ui/Report.svelte +28 -0
- package/dist/profiler/ui/Report.svelte.d.ts +20 -0
- package/dist/profiler/ui/ReportBody.svelte +2592 -0
- package/dist/profiler/ui/ReportBody.svelte.d.ts +21 -0
- package/dist/profiler/ui/Run.svelte +17 -0
- package/dist/profiler/ui/Run.svelte.d.ts +14 -0
- package/dist/profiler/ui/RunView.svelte +146 -0
- package/dist/profiler/ui/RunView.svelte.d.ts +10 -0
- package/dist/profiler/ui/ScoreCard.svelte +197 -0
- package/dist/profiler/ui/ScoreCard.svelte.d.ts +7 -0
- package/dist/profiler/ui/Scrub.svelte +293 -0
- package/dist/profiler/ui/Scrub.svelte.d.ts +18 -0
- package/dist/profiler/ui/SeedExplainer.svelte +193 -0
- package/dist/profiler/ui/SeedExplainer.svelte.d.ts +16 -0
- package/dist/profiler/ui/ShareLink.svelte +148 -0
- package/dist/profiler/ui/ShareLink.svelte.d.ts +8 -0
- package/dist/profiler/ui/Shell.svelte +50 -0
- package/dist/profiler/ui/Shell.svelte.d.ts +27 -0
- package/dist/profiler/ui/Sidebar.svelte +312 -0
- package/dist/profiler/ui/Sidebar.svelte.d.ts +7 -0
- package/dist/profiler/ui/Site.svelte +20 -0
- package/dist/profiler/ui/Site.svelte.d.ts +23 -0
- package/dist/profiler/ui/SiteView.svelte +385 -0
- package/dist/profiler/ui/SiteView.svelte.d.ts +27 -0
- package/dist/profiler/ui/ThemeSwitch.svelte +78 -0
- package/dist/profiler/ui/ThemeSwitch.svelte.d.ts +3 -0
- package/dist/profiler/ui/Timeline.svelte +672 -0
- package/dist/profiler/ui/Timeline.svelte.d.ts +22 -0
- package/dist/profiler/ui/Treemap.svelte +278 -0
- package/dist/profiler/ui/Treemap.svelte.d.ts +20 -0
- package/dist/profiler/ui/Upload.svelte +19 -0
- package/dist/profiler/ui/Upload.svelte.d.ts +10 -0
- package/dist/profiler/ui/UploadForm.svelte +59 -0
- package/dist/profiler/ui/UploadForm.svelte.d.ts +6 -0
- package/dist/profiler/ui/WaitingTable.svelte +55 -0
- package/dist/profiler/ui/WaitingTable.svelte.d.ts +14 -0
- package/dist/profiler/ui/Waterfall.svelte +371 -0
- package/dist/profiler/ui/Waterfall.svelte.d.ts +7 -0
- package/dist/profiler/ui/WhatIf.svelte +87 -0
- package/dist/profiler/ui/WhatIf.svelte.d.ts +18 -0
- package/dist/profiler/ui/fake-progress.svelte.d.ts +15 -0
- package/dist/profiler/ui/fake-progress.svelte.js +38 -0
- package/dist/profiler/ui/format.d.ts +20 -0
- package/dist/profiler/ui/format.js +71 -0
- package/dist/profiler/ui/permalink.d.ts +9 -0
- package/dist/profiler/ui/permalink.js +108 -0
- package/dist/profiler/ui/press.d.ts +5 -0
- package/dist/profiler/ui/press.js +19 -0
- package/dist/profiler/ui/profiler.css +650 -0
- package/dist/profiler/ui/report-data.d.ts +189 -0
- package/dist/profiler/ui/report-data.js +393 -0
- package/dist/profiler/ui/report-request.d.ts +6 -0
- package/dist/profiler/ui/report-request.js +50 -0
- package/dist/profiler/ui/row-anchor.svelte.d.ts +12 -0
- package/dist/profiler/ui/row-anchor.svelte.js +44 -0
- package/dist/profiler/ui/sort.svelte.d.ts +14 -0
- package/dist/profiler/ui/sort.svelte.js +37 -0
- package/dist/profiler/ui/store.d.ts +45 -0
- package/dist/profiler/ui/store.js +92 -0
- package/dist/profiler/visit.d.ts +389 -0
- package/dist/profiler/visit.js +1002 -0
- package/dist/report.d.ts +5 -0
- package/dist/report.js +4 -0
- package/dist/server.d.ts +11 -0
- package/dist/server.js +38 -0
- package/dist/vite.d.ts +12 -0
- package/dist/vite.js +78 -0
- package/package.json +103 -3
|
@@ -0,0 +1,4921 @@
|
|
|
1
|
+
import { analyze, analyze_heap, categorize, chunk_component_renamer, heap_by_component, learn_script_urls, sourcemap_resolver } from "./analyze.js";
|
|
2
|
+
import { another_routes_file, route_dirs } from "./route-files.js";
|
|
3
|
+
import { TRACE_HEADER, encode_trace } from "./net.js";
|
|
4
|
+
import { derive_findings, is_dump, island_host_renamer, island_name, island_rows_of, page_score_of, path_template, report_dump, report_json } from "./report.js";
|
|
5
|
+
import { is_ogp, ogp_decode, ogp_encode, recover_ogp_bytes } from "./crypto.js";
|
|
6
|
+
import { AFTER_LOGIN_PARAM, SESSION_COOKIE, describe_cookie_diagnosis, raw_cookie_values, session_cookie_values } from "./session-cookie.js";
|
|
7
|
+
import { label_call, load_lane_of, phase_of_frame } from "./timeline.js";
|
|
8
|
+
import { merge_visits, parse_visit } from "./visit.js";
|
|
9
|
+
import { own_requests } from "./own-requests.js";
|
|
10
|
+
import { MAX_VISITS_PER_KEY } from "./storage/visits.js";
|
|
11
|
+
import { hole_slots } from "./hole-slots.js";
|
|
12
|
+
import { client_windows, interaction_windows } from "./client-windows.js";
|
|
13
|
+
import { byte_strip } from "./byte-strip.js";
|
|
14
|
+
import { stream_tail } from "./stream-tail.js";
|
|
15
|
+
import { BEACON_STANDALONE_TAG } from "./beacon-standalone.js";
|
|
16
|
+
import { build_river, load_return_keys, seed_key_bytes } from "./river.js";
|
|
17
|
+
import { SinkBuffer } from "./sink.js";
|
|
18
|
+
import { attribute_gc, gc_kind, heap_sites } from "./gc.js";
|
|
19
|
+
import { alloc_timeline } from "./alloc.js";
|
|
20
|
+
import { contention } from "./contention.js";
|
|
21
|
+
import { build_lineage, data_prop_names, data_reads, whole_read_line } from "./lineage.js";
|
|
22
|
+
import { build_ledger, pkg_of } from "./ledger.js";
|
|
23
|
+
import { at_line, build_drill, call_group, tag_fills } from "./drill.js";
|
|
24
|
+
import { forecast_of } from "./forecast.js";
|
|
25
|
+
import { site_fixes } from "./site-fixes.js";
|
|
26
|
+
import { may_be_chunk, module_category, module_lookup } from "./module-map.js";
|
|
27
|
+
import { embedded_chunk, embedded_files, embedded_map_of, embedded_source, load_embedded_maps } from "./embedded-maps.js";
|
|
28
|
+
import { module_imports, route_imports } from "./route-imports.js";
|
|
29
|
+
import { app_relative } from "./app-path.js";
|
|
30
|
+
import { attr_sites, doc_diff } from "./doc-diff.js";
|
|
31
|
+
import { almost_same_document_pattern, counted_sync_io, find_cold_caches, find_patterns, find_same_answers, find_wait_patterns, heap_growth, is_clear_leak, is_flat, late_island_pattern, render_per_item_pattern, same_document_pattern, same_work_pattern, seed_whole_pattern, stable_answers, varied_answers, wait_save_on_path } from "./patterns.js";
|
|
32
|
+
import { constant_work, each_blocks, formatter_rewrite, hoist_plan, key_sources, late_island_keys, line_rw, load_inputs, names_on, parent_use, tokens } from "./source-scan.js";
|
|
33
|
+
import { compare_reports, island_files_diff, page_history, vitals_moved as vitals_moved$1 } from "./compare.js";
|
|
34
|
+
import { gzip_large } from "./compress.js";
|
|
35
|
+
import { io_kind, mark_left_each_run } from "./async-io.js";
|
|
36
|
+
import { set_span_recorder } from "./span.js";
|
|
37
|
+
import { register_profiler_file } from "./frames.js";
|
|
38
|
+
import { app_asset_rel, client_dir_candidates, client_file_finder } from "./client-files.js";
|
|
39
|
+
import { build_profiler_router } from "./profiler-router.js";
|
|
40
|
+
import { detect_dev } from "./env.js";
|
|
41
|
+
import { refresh_target } from "./refresh-stub.js";
|
|
42
|
+
import { assets_diff, engine_of, hydration_hazards, vital_parts, weigh_assets } from "@ogygia/core/insights";
|
|
43
|
+
import { hole_stats_of, mark_personal, request_stats_of, set_batch_hole_listener, set_request_stats_detail } from "@ogygia/core/internal/server";
|
|
44
|
+
import { fnv1a32 } from "@ogygia/core/plugin/utils";
|
|
45
|
+
import { chunkBarrels, chunkContents, chunkDuplicates, chunkHeavy, islandHazards, islandPageKeys, islandPageWhy } from "virtual:ogygia/island-deps";
|
|
46
|
+
import { error, isHttpError, isRedirect } from "@ogygia/router";
|
|
47
|
+
// src/profiler/index.ts
|
|
48
|
+
/** Set on the login page's URL when a login just succeeded and the session still did not arrive. */
|
|
49
|
+
const LOGIN_FAILED_PARAM = "session";
|
|
50
|
+
/** I/O resources a line can wait on one after another, as a readable target. Timers and ticks
|
|
51
|
+
* are left out: a timer awaited per turn is the `yield-per-item` pattern, read from the code. */
|
|
52
|
+
const WAIT_TARGET = {
|
|
53
|
+
FSREQCALLBACK: "file access",
|
|
54
|
+
FSREQPROMISE: "file access",
|
|
55
|
+
GETADDRINFOREQWRAP: "DNS lookup",
|
|
56
|
+
GETNAMEINFOREQWRAP: "DNS lookup",
|
|
57
|
+
QUERYWRAP: "DNS query",
|
|
58
|
+
TCPCONNECTWRAP: "socket connect",
|
|
59
|
+
PIPECONNECTWRAP: "pipe connect"
|
|
60
|
+
};
|
|
61
|
+
/** A response body read chunk by chunk, each chunk's end offset stamped with when it arrived
|
|
62
|
+
* (ms after `t0`): the byte strip's "left the server at". */
|
|
63
|
+
async function read_timed(res, t0) {
|
|
64
|
+
const reader = res.body?.getReader();
|
|
65
|
+
if (!reader) return {
|
|
66
|
+
text: await res.text(),
|
|
67
|
+
chunks: []
|
|
68
|
+
};
|
|
69
|
+
const dec = new TextDecoder();
|
|
70
|
+
let text = "";
|
|
71
|
+
const chunks = [];
|
|
72
|
+
for (;;) {
|
|
73
|
+
const { value, done } = await reader.read();
|
|
74
|
+
if (done) break;
|
|
75
|
+
text += dec.decode(value, { stream: true });
|
|
76
|
+
if (chunks.length < 4e3) chunks.push({
|
|
77
|
+
end: text.length,
|
|
78
|
+
t: round2(performance.now() - t0)
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
text += dec.decode();
|
|
82
|
+
return {
|
|
83
|
+
text,
|
|
84
|
+
chunks
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
register_profiler_file();
|
|
88
|
+
const MAX_VITALS_PATHS = 200;
|
|
89
|
+
const MAX_VITALS_SAMPLES = 30;
|
|
90
|
+
const MAX_SAMPLED_WINDOWS = 60;
|
|
91
|
+
const SAMPLED_FUNCTIONS_PER_WINDOW = 150;
|
|
92
|
+
const MAX_BEACON_FPS = 2e3;
|
|
93
|
+
const MAX_BEACON_SAMPLES = 64;
|
|
94
|
+
const MAX_BEACON_BODY = 65536;
|
|
95
|
+
const MAX_BEACON_CPU_BODY = 6291456;
|
|
96
|
+
const MAX_BEACON_VISIT_BODY = 524288;
|
|
97
|
+
/** the allocation sampler's interval while attributing GC: coarse on purpose (64 KB, against the
|
|
98
|
+
* 16 KB the allocators table used) — the attribution needs shares, not every object, and every
|
|
99
|
+
* sample is a record V8 keeps until the read */
|
|
100
|
+
const GC_SAMPLING_BYTES = 65536;
|
|
101
|
+
/** the live-objects sampler of the memory passes after the timed runs (what one render keeps, and
|
|
102
|
+
* the growth check): finer, because those are VERDICTS on a line, not shares. At 64 KB a 1 MB
|
|
103
|
+
* cache entry is ~14 samples, and a 4-entry cache's halfway-to-end ratio swung 1.10–2.00 around
|
|
104
|
+
* its true 1.33 (the leak line is 1.85): the slow-patterns decoy read "grows" 1 run in ~17, and a
|
|
105
|
+
* real leak was missed now and then. At 16 KB it stays 1.16–1.57. These renders are not timed:
|
|
106
|
+
* it costs about half a second of the profile's wall time on the heaviest test page, no timing. */
|
|
107
|
+
const LIVE_SAMPLING_BYTES = 16384;
|
|
108
|
+
/** the fine heap series (alloc.ts): the timer's period and the most samples one window keeps */
|
|
109
|
+
const HEAP_SERIES_PERIOD_MS = 20;
|
|
110
|
+
const HEAP_SERIES_MAX = 2e3;
|
|
111
|
+
/** the sampler keeps objects already collected: the ALLOCATION stream, what drives the collector,
|
|
112
|
+
* not just what is still alive when the profile is read */
|
|
113
|
+
const GC_SAMPLING_FLAGS = {
|
|
114
|
+
includeObjectsCollectedByMajorGC: true,
|
|
115
|
+
includeObjectsCollectedByMinorGC: true
|
|
116
|
+
};
|
|
117
|
+
/** how long a page profile waits for a background window (trap / sampler) to finish — the
|
|
118
|
+
* longest such window is the trap's 5 s */
|
|
119
|
+
const PAGE_WAITS_FOR_BACKGROUND_MS = 8e3;
|
|
120
|
+
/** a started window stops itself after this, even with no stop (it holds the recorder) */
|
|
121
|
+
const WINDOW_MAX_MS = 6e4;
|
|
122
|
+
/** the header a test's driver sends with each request of a window (its signed id) */
|
|
123
|
+
const WINDOW_HEADER = "x-og-window";
|
|
124
|
+
/** a window's requests sit in the store's visit rows under this page key (never a real path) */
|
|
125
|
+
const WINDOW_PAGE_PREFIX = "\0window:";
|
|
126
|
+
const MAX_VISITS_PER_PAGE = 5;
|
|
127
|
+
/** a screen narrower than this is a phone's (its own first screen, its own hidden islands) */
|
|
128
|
+
const NARROW_SCREEN_PX = 768;
|
|
129
|
+
/** Svelte's client runtime, by the names that show up under a component while it hydrates */
|
|
130
|
+
/** the package in a CDN script's path: `/npm/@scope/name@8/dist/x.js`, `/name@1.2.3/x.js`, `/gh/user/repo@v/` */
|
|
131
|
+
const CDN_PKG_RE = /\/(?:npm\/)?((?:@[\w.-]+\/)?[\w.-]+)@[\w.^~-]+\//;
|
|
132
|
+
const SVELTE_CLIENT_FN_RE = /^(?:set_text|set_attribute|set_class|set_style|template|from_html|child|sibling|first_child|append|mount|hydrate|hydrate_template|each|if_block|render_effect|template_effect|effect|derived|proxy|get|set|push|pop|init|reset|next|skip_nodes|create_text|remove_nodes|event|delegate|bind_value|component|snippet|slot|head)$/;
|
|
133
|
+
/**
|
|
134
|
+
* Chromium's JS Self-Profiling trace (`Profiler.stop()`) as a V8 cpuprofile: a stack is a chain of
|
|
135
|
+
* frames, so every distinct stack becomes one node whose parent is its parent stack; the samples
|
|
136
|
+
* are timestamps (ms) → time deltas (µs); a sample with no stack is idle. Bounded (samples,
|
|
137
|
+
* stacks); `null` for anything not shaped like a trace. Exported for the tests.
|
|
138
|
+
*/
|
|
139
|
+
function self_profile_to_cpuprofile(trace) {
|
|
140
|
+
const t = trace;
|
|
141
|
+
if (!t || !Array.isArray(t.frames) || !Array.isArray(t.stacks) || !Array.isArray(t.samples)) return null;
|
|
142
|
+
const resources = Array.isArray(t.resources) ? t.resources.map((r) => typeof r === "string" ? r : "") : [];
|
|
143
|
+
const frames = t.frames.slice(0, 5e4);
|
|
144
|
+
const stacks = t.stacks.slice(0, 1e5);
|
|
145
|
+
const samples = t.samples.slice(0, 2e5);
|
|
146
|
+
const frame_of = (i) => {
|
|
147
|
+
const f = frames[i];
|
|
148
|
+
if (!f) return {
|
|
149
|
+
functionName: "(unknown)",
|
|
150
|
+
url: "",
|
|
151
|
+
lineNumber: 0,
|
|
152
|
+
columnNumber: 0
|
|
153
|
+
};
|
|
154
|
+
const rid = typeof f.resourceId === "number" ? f.resourceId : -1;
|
|
155
|
+
return {
|
|
156
|
+
functionName: typeof f.name === "string" ? f.name : "",
|
|
157
|
+
url: rid >= 0 ? resources[rid] ?? "" : "",
|
|
158
|
+
lineNumber: typeof f.line === "number" ? Math.max(0, f.line - 1) : 0,
|
|
159
|
+
columnNumber: typeof f.column === "number" ? Math.max(0, f.column - 1) : 0
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
const nodes = [{
|
|
163
|
+
id: 1,
|
|
164
|
+
callFrame: {
|
|
165
|
+
functionName: "(root)",
|
|
166
|
+
url: "",
|
|
167
|
+
lineNumber: 0,
|
|
168
|
+
columnNumber: 0
|
|
169
|
+
},
|
|
170
|
+
children: [2]
|
|
171
|
+
}, {
|
|
172
|
+
id: 2,
|
|
173
|
+
callFrame: {
|
|
174
|
+
functionName: "(idle)",
|
|
175
|
+
url: "",
|
|
176
|
+
lineNumber: 0,
|
|
177
|
+
columnNumber: 0
|
|
178
|
+
}
|
|
179
|
+
}];
|
|
180
|
+
const children = /* @__PURE__ */ new Map([[1, [2]]]);
|
|
181
|
+
for (let i = 0; i < stacks.length; i++) {
|
|
182
|
+
const s = stacks[i];
|
|
183
|
+
const fid = typeof s?.frameId === "number" ? s.frameId : -1;
|
|
184
|
+
const parent = typeof s?.parentId === "number" && s.parentId >= 0 && s.parentId < stacks.length ? s.parentId + 3 : 1;
|
|
185
|
+
nodes.push({
|
|
186
|
+
id: i + 3,
|
|
187
|
+
callFrame: frame_of(fid)
|
|
188
|
+
});
|
|
189
|
+
(children.get(parent) ?? children.set(parent, []).get(parent)).push(i + 3);
|
|
190
|
+
}
|
|
191
|
+
for (const n of nodes) {
|
|
192
|
+
const ch = children.get(n.id);
|
|
193
|
+
if (ch?.length) n.children = ch;
|
|
194
|
+
}
|
|
195
|
+
const sample_ids = [];
|
|
196
|
+
const deltas = [];
|
|
197
|
+
let last = null;
|
|
198
|
+
let first = 0;
|
|
199
|
+
for (const s of samples) {
|
|
200
|
+
const ts = typeof s?.timestamp === "number" ? s.timestamp : null;
|
|
201
|
+
if (ts === null) continue;
|
|
202
|
+
if (last === null) first = ts;
|
|
203
|
+
const sid = typeof s?.stackId === "number" && s.stackId >= 0 && s.stackId < stacks.length ? s.stackId + 3 : 2;
|
|
204
|
+
sample_ids.push(sid);
|
|
205
|
+
deltas.push(last === null ? 0 : Math.max(0, Math.round((ts - last) * 1e3)));
|
|
206
|
+
last = ts;
|
|
207
|
+
}
|
|
208
|
+
if (!sample_ids.length) return null;
|
|
209
|
+
return {
|
|
210
|
+
nodes,
|
|
211
|
+
startTime: Math.round(first * 1e3),
|
|
212
|
+
endTime: Math.round((last ?? first) * 1e3),
|
|
213
|
+
samples: sample_ids,
|
|
214
|
+
timeDeltas: deltas
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const MAX_BEACON_ISLANDS = 400;
|
|
218
|
+
/** the tag the runtime looks for: its content is the beacon endpoint */
|
|
219
|
+
const BEACON_META = "ogygia-profiler-beacon";
|
|
220
|
+
/** an island fingerprint: 8–32 lowercase hex chars, checked without a regex (per beacon sample) */
|
|
221
|
+
function is_hex_id(s) {
|
|
222
|
+
if (s.length < 8 || s.length > 32) return false;
|
|
223
|
+
for (let i = 0; i < s.length; i++) {
|
|
224
|
+
const c = s.charCodeAt(i);
|
|
225
|
+
if (!(c >= 48 && c <= 57 || c >= 97 && c <= 102)) return false;
|
|
226
|
+
}
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
/** GET each URL through the app's own fetch and count its bytes — the JS weight of an island's
|
|
230
|
+
* closure. Cached per process (immutable hashed assets), a handful at a time, capped in time so
|
|
231
|
+
* a slow static host can never hold a report hostage. */
|
|
232
|
+
async function weigh_urls(urls, fetch_url, cache, budget_ms = 4e3) {
|
|
233
|
+
const todo = [...new Set(urls)].filter((u) => u && !cache.has(u));
|
|
234
|
+
const deadline = Date.now() + budget_ms;
|
|
235
|
+
let i = 0;
|
|
236
|
+
const worker = async () => {
|
|
237
|
+
while (i < todo.length && Date.now() < deadline) {
|
|
238
|
+
const u = todo[i++];
|
|
239
|
+
try {
|
|
240
|
+
const res = await with_timeout(fetch_url(u), Math.max(200, deadline - Date.now()), "weigh");
|
|
241
|
+
if (!res.ok) {
|
|
242
|
+
cache.set(u, null);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const len = Number(res.headers.get("content-length"));
|
|
246
|
+
const bytes = !res.headers.get("content-encoding") && Number.isFinite(len) && len > 0 ? len : (await res.arrayBuffer()).byteLength;
|
|
247
|
+
cache.set(u, bytes);
|
|
248
|
+
} catch {
|
|
249
|
+
cache.set(u, null);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
await Promise.all(Array.from({ length: Math.min(6, todo.length) }, worker));
|
|
254
|
+
const out = {};
|
|
255
|
+
for (const u of new Set(urls)) {
|
|
256
|
+
const b = cache.get(u);
|
|
257
|
+
if (typeof b === "number") out[u] = b;
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
const percentile = (sorted, p) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] ?? 0;
|
|
262
|
+
function summarize_measures(entries) {
|
|
263
|
+
const by_name = /* @__PURE__ */ new Map();
|
|
264
|
+
for (const e of entries) {
|
|
265
|
+
let t = by_name.get(e.name);
|
|
266
|
+
if (!t) by_name.set(e.name, t = {
|
|
267
|
+
name: e.name,
|
|
268
|
+
count: 0,
|
|
269
|
+
total_ms: 0,
|
|
270
|
+
max_ms: 0
|
|
271
|
+
});
|
|
272
|
+
t.count++;
|
|
273
|
+
t.total_ms += e.ms;
|
|
274
|
+
if (e.ms > t.max_ms) t.max_ms = e.ms;
|
|
275
|
+
}
|
|
276
|
+
return [...by_name.values()].map((t) => ({
|
|
277
|
+
...t,
|
|
278
|
+
total_ms: round2(t.total_ms),
|
|
279
|
+
max_ms: round2(t.max_ms)
|
|
280
|
+
})).sort((a, b) => b.total_ms - a.total_ms).slice(0, 30);
|
|
281
|
+
}
|
|
282
|
+
const MAX_SPANS_PER_REQUEST = 2e3;
|
|
283
|
+
const MAX_WINDOW_SPANS = 2e4;
|
|
284
|
+
const MAX_TAGS = 16;
|
|
285
|
+
/** the process's live profiler (the newest): an older one's background timers stand down */
|
|
286
|
+
const LIVE_PROFILER = Symbol.for("ogygia.profiler.live");
|
|
287
|
+
const MAX_NET_PER_REQUEST = 100;
|
|
288
|
+
const MAX_WINDOW_NET = 2e3;
|
|
289
|
+
const MAX_BACKGROUND_NET = 300;
|
|
290
|
+
const RECORDING_MAX_MS = 35e3;
|
|
291
|
+
const RECORDING_HARD_CAP_MS = 3e4;
|
|
292
|
+
const PER_RENDER_TIMEOUT_MS = 15e3;
|
|
293
|
+
/** the heap's fill (of its limit) past which no timed render is made: one more could end the process */
|
|
294
|
+
const HEAP_STOP = .85;
|
|
295
|
+
/** …and past which the extra renders (what a render keeps, the growth check) are skipped */
|
|
296
|
+
const HEAP_EXTRAS = .7;
|
|
297
|
+
/** …and past which a profile does not start: the warm-up renders come before any check, and a
|
|
298
|
+
* render's own peak (not only what it keeps) needs the room */
|
|
299
|
+
const HEAP_START = .75;
|
|
300
|
+
/** the main thread's work in a render: the code on the sampled stacks and the collector's pauses */
|
|
301
|
+
const MAIN_THREAD_CPU = /* @__PURE__ */ new Set([
|
|
302
|
+
"app",
|
|
303
|
+
"component",
|
|
304
|
+
"dependency",
|
|
305
|
+
"node",
|
|
306
|
+
"svelte",
|
|
307
|
+
"gc"
|
|
308
|
+
]);
|
|
309
|
+
/** app modules whose built lines are their source lines, near enough to match by text */
|
|
310
|
+
const SCRIPT_MODULE_EXTS = [
|
|
311
|
+
".ts",
|
|
312
|
+
".js",
|
|
313
|
+
".mts",
|
|
314
|
+
".mjs",
|
|
315
|
+
".cts",
|
|
316
|
+
".cjs"
|
|
317
|
+
];
|
|
318
|
+
/** marks the profiler's keep-the-answers render (its value: the recording's token) */
|
|
319
|
+
const ANSWERS_HEADER = "x-og-profiler-answers";
|
|
320
|
+
/** renders the keep-the-answers measure takes at most (their median is the figure) */
|
|
321
|
+
const ANSWER_RENDERS = 3;
|
|
322
|
+
/** an inspector-unavailable recording error (edge runtimes) vs a real profiling failure */
|
|
323
|
+
const INSPECTOR_ERR = /inspector/;
|
|
324
|
+
const WIN_DRIVE_RE = /^[A-Za-z]:[\\/]/;
|
|
325
|
+
const QUERY_SUFFIX_RE = /\?.*$/;
|
|
326
|
+
/** the seed script in a rendered document (report time, one document: not a hot path) */
|
|
327
|
+
const SEED_SCRIPT_RE = /<script\b[^>]*type="application\/ogygia-page"[^>]*>([\s\S]*?)<\/script>/i;
|
|
328
|
+
const SRC_RELATIVE_RE = /(?:^|[/\\])src[/\\](.*)$/;
|
|
329
|
+
const BACKSLASH_G = /\\/g;
|
|
330
|
+
const PATH_SEP_RE = /[/\\]/;
|
|
331
|
+
const TRAILING_SLASH_RE = /\/$/;
|
|
332
|
+
/** a WebAssembly script "file" as V8 names it: `/wasm/00034eea`, `wasm://wasm/…` */
|
|
333
|
+
const WASM_FRAME_RE = /^(?:wasm:\/\/|\/wasm\/)|[/\\]wasm\/[0-9a-f]+$/;
|
|
334
|
+
/** a resolved caller that lives in node_modules or Kit's runtime source: the framework, not the app */
|
|
335
|
+
const FRAMEWORK_SOURCE_RE = /[/\\]node_modules[/\\]|(?:^|[/\\])runtime[/\\](?:server|app|telemetry)[/\\]/;
|
|
336
|
+
const NODE_MODULES_RE = /[/\\]node_modules[/\\]/;
|
|
337
|
+
/** a Kit endpoint file in a resolved caller string (`+server.ts`, or its built `_server.ts.js`) */
|
|
338
|
+
const SERVER_ROUTE_FILE_RE = /[/\\](?:\+server\.[jt]s|_server\.[jt]s\.js):\d+/;
|
|
339
|
+
/** the file inside a resolved caller string: `name (file:line)` or `file:line` */
|
|
340
|
+
const CALLER_FILE_RE = /(?:\(|^)([^()]+):\d+\)?$/;
|
|
341
|
+
/** what the dev source peek may read */
|
|
342
|
+
const SOURCE_EXT_RE = /\.(?:svelte|ts|js|mjs|cjs|tsx|jsx|svx|md|json)$/;
|
|
343
|
+
const SERVERLESS_RESERVE_MS = 5e3;
|
|
344
|
+
/** the app lines the pattern finder reads, and the ones the report keeps and shows */
|
|
345
|
+
const LEDGER_FOR_PATTERNS = 200;
|
|
346
|
+
const LEDGER_SHOWN = 40;
|
|
347
|
+
/** how long a page recording waits for its store write before answering (inside the reserve) */
|
|
348
|
+
const PERSIST_WAIT_MS = 3e3;
|
|
349
|
+
/** The whole-request wall budget (ms) before the platform's gateway kills a /page profile — Infinity
|
|
350
|
+
* on a real server (adapter-node, dev). An explicit OGYGIA_PROFILER_BUDGET_MS wins over detection. */
|
|
351
|
+
function detect_request_budget_ms() {
|
|
352
|
+
const override = Number(process.env.OGYGIA_PROFILER_BUDGET_MS);
|
|
353
|
+
if (Number.isFinite(override) && override > 0) return override;
|
|
354
|
+
const env = process.env;
|
|
355
|
+
if (env.VERCEL) return 3e5;
|
|
356
|
+
if (env.NETLIFY) return 1e4;
|
|
357
|
+
if (env.AWS_LAMBDA_FUNCTION_NAME || env.LAMBDA_TASK_ROOT) return 3e4;
|
|
358
|
+
return Infinity;
|
|
359
|
+
}
|
|
360
|
+
/** Time allotted to the profiling work itself (warm-up + runs + coverage), keeping RESERVE for the
|
|
361
|
+
* rest of the request. Halves as a floor so a very small budget still spends most of itself working. */
|
|
362
|
+
function serverless_work_budget_ms() {
|
|
363
|
+
const req = detect_request_budget_ms();
|
|
364
|
+
if (!Number.isFinite(req)) return Infinity;
|
|
365
|
+
return Math.max(req - SERVERLESS_RESERVE_MS, Math.floor(req * .5));
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Page mode renders the SAME page N times, so its outbound calls are the same handful repeated N
|
|
369
|
+
* times — a waterfall of 5×3 identical bars is noise. Given each run's [start,end] window, keep only
|
|
370
|
+
* ONE representative render's net + io (the run that captured the most calls — the fullest picture;
|
|
371
|
+
* ties go to the later, warmer run) so the network view reads as "one request + its leaf calls". CPU
|
|
372
|
+
* stays merged across all runs (more samples = steadier median); network is inherently per-render.
|
|
373
|
+
*/
|
|
374
|
+
function scope_net_to_one_run(cap, windows) {
|
|
375
|
+
if (windows.length <= 1) return windows[0] ?? null;
|
|
376
|
+
const within = (t, w) => t >= w.start && t <= w.end;
|
|
377
|
+
let best = windows[0];
|
|
378
|
+
let best_n = -1;
|
|
379
|
+
for (const w of windows) {
|
|
380
|
+
const n = cap.net.reduce((s, c) => s + (within(c.start, w) ? 1 : 0), 0);
|
|
381
|
+
if (n >= best_n) {
|
|
382
|
+
best_n = n;
|
|
383
|
+
best = w;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
cap.net = cap.net.filter((c) => within(c.start, best));
|
|
387
|
+
cap.io_ops = cap.io_ops.filter((o) => within(o.start, best));
|
|
388
|
+
return best;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* The profiler as a class — state lives in fields, not closures, matching the
|
|
392
|
+
* `OgygiaHandle` house style. `profiler(options)` (below) constructs one and
|
|
393
|
+
* returns its bound `handle`.
|
|
394
|
+
*/
|
|
395
|
+
var Profiler = class {
|
|
396
|
+
base;
|
|
397
|
+
sample_interval;
|
|
398
|
+
max_reports;
|
|
399
|
+
ring_size;
|
|
400
|
+
want_network;
|
|
401
|
+
want_heap;
|
|
402
|
+
dev;
|
|
403
|
+
want_server_timing;
|
|
404
|
+
secret;
|
|
405
|
+
ui_enabled;
|
|
406
|
+
#disabled;
|
|
407
|
+
#ring = [];
|
|
408
|
+
#reports = /* @__PURE__ */ new Map();
|
|
409
|
+
/** the browser's hydration timings, by island fingerprint (`/beacon`) — joined into reports at view time */
|
|
410
|
+
#beacons = /* @__PURE__ */ new Map();
|
|
411
|
+
/** the browser's web vitals per page path (`/beacon`), the profiler user's own visits */
|
|
412
|
+
#vitals = /* @__PURE__ */ new Map();
|
|
413
|
+
/** the app's own browser marks per page path, per name (`mark()` from ogygia/profiler/client) */
|
|
414
|
+
#marks = /* @__PURE__ */ new Map();
|
|
415
|
+
/** the trap (catch the slow one) and the always-on sampler: their state */
|
|
416
|
+
#trap;
|
|
417
|
+
#client_cpu;
|
|
418
|
+
/** the browser's CPU profile of hydration, per page path (the profiler user's latest visit) */
|
|
419
|
+
#client_cpus = /* @__PURE__ */ new Map();
|
|
420
|
+
/** per page, the slowest interaction's CPU (its wait and its handlers), the latest trace */
|
|
421
|
+
#interaction_cpus = /* @__PURE__ */ new Map();
|
|
422
|
+
/** the beacon's visits per page (the browser's picture of a page load), a few kept each */
|
|
423
|
+
#visits = /* @__PURE__ */ new Map();
|
|
424
|
+
/** THE SINK: rows an ephemeral host posts out before it dies (see sink.ts) */
|
|
425
|
+
#sink = new SinkBuffer();
|
|
426
|
+
#sink_url = null;
|
|
427
|
+
#sink_key = null;
|
|
428
|
+
#sink_every = 1e4;
|
|
429
|
+
#sink_timer = null;
|
|
430
|
+
#sink_inflight = false;
|
|
431
|
+
#sink_last = null;
|
|
432
|
+
#trap_caught = 0;
|
|
433
|
+
#trap_running = false;
|
|
434
|
+
#trap_timer = null;
|
|
435
|
+
#sample;
|
|
436
|
+
#sampled = [];
|
|
437
|
+
#sample_timer = null;
|
|
438
|
+
#background_note = null;
|
|
439
|
+
/** bytes per asset URL, measured once per process */
|
|
440
|
+
#weights = /* @__PURE__ */ new Map();
|
|
441
|
+
/** every page asset weighed so far (hashed assets never change): page-assets.ts */
|
|
442
|
+
#asset_weights = /* @__PURE__ */ new Map();
|
|
443
|
+
#background_net = [];
|
|
444
|
+
#window_net = null;
|
|
445
|
+
#inflight = 0;
|
|
446
|
+
#recording_since = 0;
|
|
447
|
+
/** performance.now() when this instance's first request arrived (its cold start, on Lambda) */
|
|
448
|
+
#first_request_pt;
|
|
449
|
+
/** requests this instance has handled */
|
|
450
|
+
#requests_seen = 0;
|
|
451
|
+
#recorder_busy = false;
|
|
452
|
+
/** page profiles waiting for a background window to give the recorder back */
|
|
453
|
+
#page_waiting = 0;
|
|
454
|
+
/** the durable report store (SQLite/Redis/Postgres/custom), when configured — else null and the
|
|
455
|
+
* in-memory map is all there is (the prior behaviour) */
|
|
456
|
+
#store = null;
|
|
457
|
+
#als = null;
|
|
458
|
+
#init_done = null;
|
|
459
|
+
/** Re-assert the `globalThis.fetch` patch before a profile (self-heal if it was replaced). */
|
|
460
|
+
#ensure_net = null;
|
|
461
|
+
/** wraps Kit's `event.fetch` (in-process same-origin dispatch) with the recorder, per attributed request */
|
|
462
|
+
#wrap_event_fetch = null;
|
|
463
|
+
/** the network layer's kept answers (net.ts): what the keep-the-answers render is served */
|
|
464
|
+
#kept = null;
|
|
465
|
+
/** the keep-the-answers render in flight: its request carries this token, and only these calls
|
|
466
|
+
* (`GET <url>`, the same answer in every timed render) are served from memory */
|
|
467
|
+
#answers = null;
|
|
468
|
+
constructor(options = {}) {
|
|
469
|
+
this.base = options.path ?? "/__profiler";
|
|
470
|
+
this.sample_interval = clamp(options.sampleInterval ?? 500, 50, 1e4);
|
|
471
|
+
this.max_reports = options.maxReports ?? 6;
|
|
472
|
+
this.ring_size = options.recentRequests ?? 500;
|
|
473
|
+
this.want_network = options.network !== false;
|
|
474
|
+
this.want_heap = options.heap !== false;
|
|
475
|
+
this.dev = detect_dev();
|
|
476
|
+
this.want_server_timing = options.serverTiming ?? this.dev;
|
|
477
|
+
this.secret = options.secret ?? process.env.OGYGIA_PROFILER_SECRET ?? "";
|
|
478
|
+
this.ui_enabled = options.enabled !== false && (this.dev || this.secret !== "");
|
|
479
|
+
this.#disabled = options.enabled === false;
|
|
480
|
+
this.#trap = options.trap && options.trap.over > 0 ? options.trap : null;
|
|
481
|
+
if (options.store) {
|
|
482
|
+
this.#store = options.store;
|
|
483
|
+
(async () => this.#store.init?.())().catch((e) => {
|
|
484
|
+
console.error("[ogygia/profiler] store init failed; falling back to in-memory:", e);
|
|
485
|
+
this.#store = null;
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
this.#client_cpu = options.clientCpu !== false;
|
|
489
|
+
if (options.sink?.url) {
|
|
490
|
+
this.#sink_url = options.sink.url;
|
|
491
|
+
this.#sink_key = options.sink.key ?? this.secret ?? null;
|
|
492
|
+
this.#sink_every = Math.max(1e3, options.sink.every ?? 1e4);
|
|
493
|
+
if (!Number.isFinite(serverless_work_budget_ms())) {
|
|
494
|
+
this.#sink_timer = setInterval(() => void this.#sink_flush(), this.#sink_every);
|
|
495
|
+
this.#sink_timer.unref?.();
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
this.#sample = options.sample ? options.sample : null;
|
|
499
|
+
globalThis[LIVE_PROFILER] = this;
|
|
500
|
+
if (!this.#disabled) set_batch_hole_listener((request, s) => this.#batch_hole(request, s));
|
|
501
|
+
}
|
|
502
|
+
/** Logged requests by their Request (a batch's holes find their request here), and the holes that
|
|
503
|
+
* settled before their request was logged. */
|
|
504
|
+
#logged = /* @__PURE__ */ new WeakMap();
|
|
505
|
+
#early_holes = /* @__PURE__ */ new WeakMap();
|
|
506
|
+
/** ONE HOLE OF A BATCH, logged as its own hole request — the shape every hole reading here knows
|
|
507
|
+
* (the cache table, the render slots, a visit's holes): its own time within the batch, no CPU of
|
|
508
|
+
* its own (the batch request carries that). */
|
|
509
|
+
#batch_hole(request, s) {
|
|
510
|
+
const base = this.#logged.get(request);
|
|
511
|
+
if (!base) {
|
|
512
|
+
const early = this.#early_holes.get(request);
|
|
513
|
+
if (early) early.push(s);
|
|
514
|
+
else this.#early_holes.set(request, [s]);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const { ms, status, ...hole } = s;
|
|
518
|
+
this.#ring.push({
|
|
519
|
+
...base,
|
|
520
|
+
ms: round2(ms),
|
|
521
|
+
cpu_ms: 0,
|
|
522
|
+
status,
|
|
523
|
+
hole,
|
|
524
|
+
og: void 0
|
|
525
|
+
});
|
|
526
|
+
if (this.#ring.length > this.ring_size) this.#ring.shift();
|
|
527
|
+
}
|
|
528
|
+
/** A newer profiler took this process over (a dev server that re-ran the app's hooks). */
|
|
529
|
+
#superseded() {
|
|
530
|
+
return globalThis[LIVE_PROFILER] !== this;
|
|
531
|
+
}
|
|
532
|
+
/** Stand down: the background timers stop (a sink sends what it holds first). */
|
|
533
|
+
#retire() {
|
|
534
|
+
if (this.#trap_timer) clearTimeout(this.#trap_timer);
|
|
535
|
+
if (this.#sample_timer) clearTimeout(this.#sample_timer);
|
|
536
|
+
this.#trap_timer = null;
|
|
537
|
+
this.#sample_timer = null;
|
|
538
|
+
if (this.#sink_timer) {
|
|
539
|
+
clearInterval(this.#sink_timer);
|
|
540
|
+
this.#sink_timer = null;
|
|
541
|
+
this.#sink_flush();
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/** Arm the background recorders (once, from the first request). Both need a real server: on a
|
|
545
|
+
* serverless host an instance lives for one request and the inspector is often unavailable. */
|
|
546
|
+
#arm_background() {
|
|
547
|
+
if (!this.#trap && !this.#sample) return;
|
|
548
|
+
if (Number.isFinite(serverless_work_budget_ms())) {
|
|
549
|
+
this.#background_note = "trap / sampling are off on this serverless host (an instance lives for one request).";
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (this.#trap && !this.#trap_timer) this.#schedule_trap(1e3);
|
|
553
|
+
if (this.#sample && !this.#sample_timer) this.#schedule_sample((this.#sample.every ?? 60) * 1e3);
|
|
554
|
+
}
|
|
555
|
+
#schedule_trap(delay_ms) {
|
|
556
|
+
this.#trap_timer = setTimeout(() => void this.#trap_cycle(), delay_ms);
|
|
557
|
+
this.#trap_timer.unref?.();
|
|
558
|
+
}
|
|
559
|
+
#schedule_sample(delay_ms) {
|
|
560
|
+
this.#sample_timer = setTimeout(() => void this.#sample_cycle(), delay_ms);
|
|
561
|
+
this.#sample_timer.unref?.();
|
|
562
|
+
}
|
|
563
|
+
/** One rolling trap window: sample coarsely, then keep the window only if a request in it blew
|
|
564
|
+
* past the threshold. The slowest such request is the report's request; the timeline is its. */
|
|
565
|
+
async #trap_cycle() {
|
|
566
|
+
const t = this.#trap;
|
|
567
|
+
this.#trap_timer = null;
|
|
568
|
+
if (this.#superseded()) return this.#retire();
|
|
569
|
+
const keep = t.keep ?? 3;
|
|
570
|
+
if (this.#trap_caught >= keep) return;
|
|
571
|
+
if (this.#page_waiting) return this.#schedule_trap(1e3);
|
|
572
|
+
if (!this.#try_acquire_recorder()) return this.#schedule_trap(2e3);
|
|
573
|
+
this.#trap_running = true;
|
|
574
|
+
this.#recording_since = Date.now();
|
|
575
|
+
try {
|
|
576
|
+
const window_ms = t.window ?? 5e3;
|
|
577
|
+
const cap = await this.#capture_window((t.interval ?? 10) * 1e3, () => new Promise((r) => setTimeout(r, window_ms)), { light: true });
|
|
578
|
+
const slow = this.#ring.filter((e) => !e.internal && e.pt !== void 0 && e.ts >= cap.t0 && e.ts + e.ms <= cap.t1 && e.ms >= t.over).sort((x, y) => y.ms - x.ms)[0];
|
|
579
|
+
if (slow) {
|
|
580
|
+
cap.window = {
|
|
581
|
+
start: slow.pt,
|
|
582
|
+
end: slow.pt + slow.ms
|
|
583
|
+
};
|
|
584
|
+
const id = await this.#finish_report(cap, {
|
|
585
|
+
trigger: "trap",
|
|
586
|
+
request: {
|
|
587
|
+
method: slow.method,
|
|
588
|
+
path: slow.path,
|
|
589
|
+
route: slow.route,
|
|
590
|
+
ms: slow.ms
|
|
591
|
+
},
|
|
592
|
+
trap_over: t.over
|
|
593
|
+
});
|
|
594
|
+
const stored = this.#reports.get(id);
|
|
595
|
+
if (stored) stored.replay = {
|
|
596
|
+
path: slow.path + (slow.search ?? ""),
|
|
597
|
+
headers: slow.replay_headers ?? {}
|
|
598
|
+
};
|
|
599
|
+
if (stored && this.#sink_url) {
|
|
600
|
+
this.#sink.push({
|
|
601
|
+
k: "trap",
|
|
602
|
+
t: stored.meta.created,
|
|
603
|
+
path: slow.path,
|
|
604
|
+
ms: slow.ms,
|
|
605
|
+
id,
|
|
606
|
+
dump: report_dump(stored.analysis, stored.meta, this.#report_extras(stored))
|
|
607
|
+
});
|
|
608
|
+
this.#sink_flush();
|
|
609
|
+
}
|
|
610
|
+
this.#trap_caught++;
|
|
611
|
+
}
|
|
612
|
+
} catch {
|
|
613
|
+
this.#trap_caught = keep;
|
|
614
|
+
} finally {
|
|
615
|
+
this.#trap_running = false;
|
|
616
|
+
this.#recording_since = 0;
|
|
617
|
+
this.#release_als();
|
|
618
|
+
this.#release_recorder();
|
|
619
|
+
}
|
|
620
|
+
if (this.#trap_caught < keep) this.#schedule_trap(1e3);
|
|
621
|
+
}
|
|
622
|
+
/** One always-on sample: a short coarse window folded into the rolling hot-functions table. */
|
|
623
|
+
async #sample_cycle() {
|
|
624
|
+
const s = this.#sample;
|
|
625
|
+
this.#sample_timer = null;
|
|
626
|
+
if (this.#superseded()) return this.#retire();
|
|
627
|
+
const every_ms = (s.every ?? 60) * 1e3;
|
|
628
|
+
if (this.#page_waiting) return this.#schedule_sample(Math.min(every_ms, 2e3));
|
|
629
|
+
if (!this.#try_acquire_recorder()) return this.#schedule_sample(Math.min(every_ms, 5e3));
|
|
630
|
+
this.#trap_running = true;
|
|
631
|
+
this.#recording_since = Date.now();
|
|
632
|
+
try {
|
|
633
|
+
const window_ms = s.window ?? 2e3;
|
|
634
|
+
const cap = await this.#capture_window((s.interval ?? 10) * 1e3, () => new Promise((r) => setTimeout(r, window_ms)), { light: true });
|
|
635
|
+
const resolver = await this.#make_resolver();
|
|
636
|
+
const a = analyze(cap.profile, resolver, void 0, void 0, void 0, this.#module_hint);
|
|
637
|
+
this.#sampled.push({
|
|
638
|
+
at: cap.t0,
|
|
639
|
+
ms: cap.duration_ms,
|
|
640
|
+
busy_ms: a.busy_ms,
|
|
641
|
+
functions: a.functions.slice(0, SAMPLED_FUNCTIONS_PER_WINDOW).map((f) => ({
|
|
642
|
+
key: f.key,
|
|
643
|
+
name: f.name,
|
|
644
|
+
url: f.url,
|
|
645
|
+
line: f.line,
|
|
646
|
+
category: f.category,
|
|
647
|
+
pkg: f.pkg,
|
|
648
|
+
self_ms: f.self_ms
|
|
649
|
+
}))
|
|
650
|
+
});
|
|
651
|
+
while (this.#sampled.length > MAX_SAMPLED_WINDOWS) this.#sampled.shift();
|
|
652
|
+
if (this.#sink_url) {
|
|
653
|
+
const w = this.#sampled[this.#sampled.length - 1];
|
|
654
|
+
this.#sink.push({
|
|
655
|
+
k: "win",
|
|
656
|
+
t0: w.at,
|
|
657
|
+
t1: w.at + w.ms,
|
|
658
|
+
fns: w.functions.slice(0, 40).map((f) => ({
|
|
659
|
+
name: f.name,
|
|
660
|
+
file: f.url,
|
|
661
|
+
self_ms: f.self_ms,
|
|
662
|
+
category: f.category
|
|
663
|
+
}))
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
} catch {
|
|
667
|
+
return;
|
|
668
|
+
} finally {
|
|
669
|
+
this.#trap_running = false;
|
|
670
|
+
this.#recording_since = 0;
|
|
671
|
+
this.#release_als();
|
|
672
|
+
this.#release_recorder();
|
|
673
|
+
}
|
|
674
|
+
this.#schedule_sample(every_ms);
|
|
675
|
+
}
|
|
676
|
+
/** The always-on table: every kept window folded, hot functions by summed self time. */
|
|
677
|
+
#sampled_summary() {
|
|
678
|
+
if (!this.#sample) return null;
|
|
679
|
+
const by_key = /* @__PURE__ */ new Map();
|
|
680
|
+
let sampled_ms = 0;
|
|
681
|
+
let busy_ms = 0;
|
|
682
|
+
for (const w of this.#sampled) {
|
|
683
|
+
sampled_ms += w.ms;
|
|
684
|
+
busy_ms += w.busy_ms;
|
|
685
|
+
for (const f of w.functions) {
|
|
686
|
+
const g = by_key.get(f.key);
|
|
687
|
+
if (g) {
|
|
688
|
+
g.self_ms = round2(g.self_ms + f.self_ms);
|
|
689
|
+
g.windows++;
|
|
690
|
+
} else by_key.set(f.key, {
|
|
691
|
+
...f,
|
|
692
|
+
windows: 1
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
every_s: this.#sample.every ?? 60,
|
|
698
|
+
window_ms: this.#sample.window ?? 2e3,
|
|
699
|
+
windows: this.#sampled.length,
|
|
700
|
+
since: this.#sampled[0]?.at ?? null,
|
|
701
|
+
sampled_ms: round2(sampled_ms),
|
|
702
|
+
busy_ms: round2(busy_ms),
|
|
703
|
+
functions: [...by_key.values()].sort((x, y) => y.self_ms - x.self_ms).slice(0, 40)
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
#collect_window(call) {
|
|
707
|
+
if (this.#window_net && this.#window_net.length < MAX_WINDOW_NET) this.#window_net.push(call);
|
|
708
|
+
}
|
|
709
|
+
/** An attributed request's context: its own calls (capped), each also offered to the window. */
|
|
710
|
+
#request_ctx(entry) {
|
|
711
|
+
const collect_window = (call) => this.#collect_window(call);
|
|
712
|
+
return {
|
|
713
|
+
entry,
|
|
714
|
+
net: [],
|
|
715
|
+
spans: [],
|
|
716
|
+
route: entry.route,
|
|
717
|
+
path: entry.path,
|
|
718
|
+
on_net(call) {
|
|
719
|
+
if (this.net.length < MAX_NET_PER_REQUEST) this.net.push(call);
|
|
720
|
+
collect_window(call);
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
#init() {
|
|
725
|
+
return this.#init_done ??= (async () => {
|
|
726
|
+
this.#arm_background();
|
|
727
|
+
if (!this.want_network) return;
|
|
728
|
+
try {
|
|
729
|
+
const { AsyncLocalStorage } = await import("node:async_hooks");
|
|
730
|
+
this.#als = new AsyncLocalStorage();
|
|
731
|
+
} catch {
|
|
732
|
+
this.#als = null;
|
|
733
|
+
}
|
|
734
|
+
if (this.#als) try {
|
|
735
|
+
const { install_net_capture, ensure_fetch_patched, wrap_event_fetch, keep_answers, kept_answer, drop_answers, answer_diff } = await import("./net.js");
|
|
736
|
+
this.#wrap_event_fetch = wrap_event_fetch;
|
|
737
|
+
this.#kept = {
|
|
738
|
+
keep: keep_answers,
|
|
739
|
+
get: kept_answer,
|
|
740
|
+
drop: drop_answers,
|
|
741
|
+
diff: answer_diff
|
|
742
|
+
};
|
|
743
|
+
await install_net_capture(this.#als, (call) => {
|
|
744
|
+
this.#collect_window(call);
|
|
745
|
+
this.#background_net.push(call);
|
|
746
|
+
if (this.#background_net.length > MAX_BACKGROUND_NET) this.#background_net.shift();
|
|
747
|
+
});
|
|
748
|
+
this.#ensure_net = ensure_fetch_patched;
|
|
749
|
+
} catch {}
|
|
750
|
+
})();
|
|
751
|
+
}
|
|
752
|
+
/** A recording is "active" only if one started recently — a run abandoned by a frozen/killed
|
|
753
|
+
* serverless invocation ages out after the cap instead of wedging the profiler forever. */
|
|
754
|
+
#recording_active() {
|
|
755
|
+
return this.#recording_since > 0 && Date.now() - this.#recording_since < RECORDING_MAX_MS;
|
|
756
|
+
}
|
|
757
|
+
/** Take the recorder slot without waiting: true if it was free (now ours), false if busy. Recordings
|
|
758
|
+
* serialize PER WORKER — one V8 inspector CPU/heap sampler + one attribution context per process — so
|
|
759
|
+
* a second recording on the same process is refused, NOT queued in memory: on serverless the process
|
|
760
|
+
* can be recycled at any moment (Amplify freezes a worker after ~30 s), so a waiter parked in memory
|
|
761
|
+
* is unreliable, and it isn't needed — a retry lands on a different, free worker. The "queue" is the
|
|
762
|
+
* CLIENT polling `/page` until a worker says yes (see RunView). */
|
|
763
|
+
#try_acquire_recorder() {
|
|
764
|
+
if (this.#recorder_busy) return false;
|
|
765
|
+
this.#recorder_busy = true;
|
|
766
|
+
return true;
|
|
767
|
+
}
|
|
768
|
+
/** Free the recorder slot. Called only when the recording is truly finished (its inspector session
|
|
769
|
+
* closed) — never from the site-unblock watchdog, which may fire while an orphaned run still holds
|
|
770
|
+
* the inspector. */
|
|
771
|
+
#release_recorder() {
|
|
772
|
+
this.#recorder_busy = false;
|
|
773
|
+
}
|
|
774
|
+
/** Production: detach the profiler's AsyncLocalStorage once a recording ends. An ALS that has
|
|
775
|
+
* run once stays enabled — every async hop in the process keeps paying a store copy for it — until
|
|
776
|
+
* `disable()`; the next `run()` re-enables it. Dev keeps it on (attribution is always on there). */
|
|
777
|
+
#release_als() {
|
|
778
|
+
if (!this.dev) this.#als?.disable();
|
|
779
|
+
}
|
|
780
|
+
async #capture_window(interval_us, work, opts = {}) {
|
|
781
|
+
const { Session } = await import("node:inspector/promises");
|
|
782
|
+
const perf_hooks = await import("node:perf_hooks");
|
|
783
|
+
await load_embedded_maps();
|
|
784
|
+
const session = new Session();
|
|
785
|
+
session.connect();
|
|
786
|
+
const histogram = perf_hooks.monitorEventLoopDelay({ resolution: 10 });
|
|
787
|
+
histogram.enable();
|
|
788
|
+
const elu0 = perf_hooks.performance.eventLoopUtilization();
|
|
789
|
+
const cpu0 = process.cpuUsage();
|
|
790
|
+
const gc_pauses = [];
|
|
791
|
+
const gc_raw = [];
|
|
792
|
+
const measures = [];
|
|
793
|
+
const ingest = (entries) => {
|
|
794
|
+
for (const e of entries) if (e.entryType === "gc") {
|
|
795
|
+
gc_pauses.push(e.duration);
|
|
796
|
+
const d = e.detail ?? {};
|
|
797
|
+
if (gc_raw.length < 5e3) gc_raw.push({
|
|
798
|
+
start: e.startTime ?? 0,
|
|
799
|
+
ms: e.duration,
|
|
800
|
+
kind: d.kind ?? 0,
|
|
801
|
+
flags: d.flags ?? 0
|
|
802
|
+
});
|
|
803
|
+
} else if (e.entryType === "measure" && measures.length < 5e3) measures.push({
|
|
804
|
+
name: e.name,
|
|
805
|
+
ms: e.duration
|
|
806
|
+
});
|
|
807
|
+
};
|
|
808
|
+
let observer;
|
|
809
|
+
try {
|
|
810
|
+
observer = new perf_hooks.PerformanceObserver((list) => ingest(list.getEntries()));
|
|
811
|
+
observer.observe({ entryTypes: ["gc", "measure"] });
|
|
812
|
+
} catch {}
|
|
813
|
+
const mem = [];
|
|
814
|
+
const t0 = Date.now();
|
|
815
|
+
const sample_mem = () => {
|
|
816
|
+
const m = process.memoryUsage();
|
|
817
|
+
mem.push({
|
|
818
|
+
t: Date.now() - t0,
|
|
819
|
+
rss: Math.round(m.rss / 1048576),
|
|
820
|
+
heap_used: Math.round(m.heapUsed / 1048576)
|
|
821
|
+
});
|
|
822
|
+
};
|
|
823
|
+
sample_mem();
|
|
824
|
+
const mem_timer = setInterval(sample_mem, 250);
|
|
825
|
+
mem_timer.unref?.();
|
|
826
|
+
const heap_series = [];
|
|
827
|
+
let heap_timer;
|
|
828
|
+
if (!opts.light) try {
|
|
829
|
+
const v8 = await import("node:v8");
|
|
830
|
+
const sample_heap = () => {
|
|
831
|
+
if (heap_series.length >= HEAP_SERIES_MAX) return;
|
|
832
|
+
heap_series.push({
|
|
833
|
+
t: perf_hooks.performance.now(),
|
|
834
|
+
mb: v8.getHeapStatistics().used_heap_size / 1048576
|
|
835
|
+
});
|
|
836
|
+
};
|
|
837
|
+
heap_timer = setInterval(sample_heap, HEAP_SERIES_PERIOD_MS);
|
|
838
|
+
heap_timer.unref?.();
|
|
839
|
+
} catch {}
|
|
840
|
+
this.#window_net = [];
|
|
841
|
+
const netmod = await import("./net.js").catch(() => null);
|
|
842
|
+
if (!opts.light) netmod?.set_stack_capture(true);
|
|
843
|
+
const spans = [];
|
|
844
|
+
let span_seq = 0;
|
|
845
|
+
const als = this.#als;
|
|
846
|
+
const { AsyncLocalStorage: SpanALS } = await import("node:async_hooks");
|
|
847
|
+
const span_als = new SpanALS();
|
|
848
|
+
set_span_recorder({
|
|
849
|
+
begin: (name, attrs) => {
|
|
850
|
+
const ctx = als?.getStore();
|
|
851
|
+
const parent = ctx?.span ?? span_als.getStore();
|
|
852
|
+
const rec = {
|
|
853
|
+
id: ++span_seq,
|
|
854
|
+
name: String(name).slice(0, 120),
|
|
855
|
+
start: perf_hooks.performance.now(),
|
|
856
|
+
ms: -1,
|
|
857
|
+
...attrs ? { attrs: { ...attrs } } : {},
|
|
858
|
+
...parent !== void 0 ? { parent } : {},
|
|
859
|
+
caller_site: netmod?.nearest_app_site(),
|
|
860
|
+
route: ctx?.route ?? null,
|
|
861
|
+
path: ctx?.path ?? null
|
|
862
|
+
};
|
|
863
|
+
if (spans.length < MAX_WINDOW_SPANS) spans.push(rec);
|
|
864
|
+
if (ctx && ctx.spans.length < MAX_SPANS_PER_REQUEST) ctx.spans.push(rec);
|
|
865
|
+
return rec;
|
|
866
|
+
},
|
|
867
|
+
within: (s, fn) => {
|
|
868
|
+
const ctx = als?.getStore();
|
|
869
|
+
return ctx && als ? als.run({
|
|
870
|
+
...ctx,
|
|
871
|
+
span: s.id
|
|
872
|
+
}, fn) : span_als.run(s.id, fn);
|
|
873
|
+
},
|
|
874
|
+
end: (s, attrs, error) => {
|
|
875
|
+
s.ms = round2(perf_hooks.performance.now() - s.start);
|
|
876
|
+
if (attrs) s.attrs = {
|
|
877
|
+
...s.attrs,
|
|
878
|
+
...attrs
|
|
879
|
+
};
|
|
880
|
+
if (error !== void 0) s.error = error instanceof Error ? error.message : String(error);
|
|
881
|
+
},
|
|
882
|
+
tag: (key, value) => {
|
|
883
|
+
const ctx = als?.getStore();
|
|
884
|
+
if (!ctx) return;
|
|
885
|
+
const tags = ctx.entry.tags ??= {};
|
|
886
|
+
if (key in tags || Object.keys(tags).length < MAX_TAGS) tags[key] = value;
|
|
887
|
+
}
|
|
888
|
+
});
|
|
889
|
+
const iomod = opts.light ? null : await import("./async-io.js").catch(() => null);
|
|
890
|
+
let io_rec = iomod ? await iomod.record_async_io() : null;
|
|
891
|
+
let heap_head = null;
|
|
892
|
+
set_request_stats_detail(true);
|
|
893
|
+
try {
|
|
894
|
+
await session.post("Profiler.enable");
|
|
895
|
+
await session.post("Profiler.setSamplingInterval", { interval: interval_us });
|
|
896
|
+
let heap_on = false;
|
|
897
|
+
if (this.want_heap && !opts.light) try {
|
|
898
|
+
await session.post("HeapProfiler.enable");
|
|
899
|
+
if (opts.gc_attr !== false) try {
|
|
900
|
+
await session.post("HeapProfiler.startSampling", {
|
|
901
|
+
samplingInterval: GC_SAMPLING_BYTES,
|
|
902
|
+
...GC_SAMPLING_FLAGS
|
|
903
|
+
});
|
|
904
|
+
heap_on = true;
|
|
905
|
+
} catch {
|
|
906
|
+
try {
|
|
907
|
+
await session.post("HeapProfiler.stopSampling");
|
|
908
|
+
await session.post("HeapProfiler.startSampling", {
|
|
909
|
+
samplingInterval: GC_SAMPLING_BYTES,
|
|
910
|
+
...GC_SAMPLING_FLAGS
|
|
911
|
+
});
|
|
912
|
+
heap_on = true;
|
|
913
|
+
} catch (e) {
|
|
914
|
+
if (process.env.OGYGIA_PROFILER_DEBUG) console.error("[ogygia/profiler] allocation sampling unavailable, GC attribution off:", e);
|
|
915
|
+
await session.post("HeapProfiler.startSampling", { samplingInterval: 16384 });
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
else await session.post("HeapProfiler.startSampling", { samplingInterval: 16384 });
|
|
919
|
+
} catch {}
|
|
920
|
+
const perf_start = perf_hooks.performance.now();
|
|
921
|
+
await session.post("Profiler.start");
|
|
922
|
+
const gc_slices = [];
|
|
923
|
+
const gc_dict = {};
|
|
924
|
+
await work();
|
|
925
|
+
const perf_end = perf_hooks.performance.now();
|
|
926
|
+
const { profile } = await session.post("Profiler.stop");
|
|
927
|
+
learn_script_urls(profile);
|
|
928
|
+
if (this.want_heap && !opts.light) try {
|
|
929
|
+
heap_head = (await session.post("HeapProfiler.stopSampling")).profile?.head ?? null;
|
|
930
|
+
if (heap_on && heap_head) {
|
|
931
|
+
const cur = heap_sites(heap_head, gc_dict);
|
|
932
|
+
const bytes = {};
|
|
933
|
+
for (const [k, v] of cur) if (v > 0) bytes[k] = v;
|
|
934
|
+
gc_slices.push({
|
|
935
|
+
t0: 0,
|
|
936
|
+
t1: round2(perf_end - perf_start),
|
|
937
|
+
bytes
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
} catch {
|
|
941
|
+
heap_head = null;
|
|
942
|
+
}
|
|
943
|
+
const t1 = Date.now();
|
|
944
|
+
sample_mem();
|
|
945
|
+
try {
|
|
946
|
+
if (observer) ingest(observer.takeRecords());
|
|
947
|
+
} catch {}
|
|
948
|
+
const cpu = process.cpuUsage(cpu0);
|
|
949
|
+
const elu = perf_hooks.performance.eventLoopUtilization(elu0);
|
|
950
|
+
const duration_ms = t1 - t0;
|
|
951
|
+
const net = this.#window_net ?? [];
|
|
952
|
+
for (const s of spans) if (s.ms < 0) s.open = true;
|
|
953
|
+
return {
|
|
954
|
+
profile,
|
|
955
|
+
heap: heap_head,
|
|
956
|
+
mem,
|
|
957
|
+
net,
|
|
958
|
+
spans,
|
|
959
|
+
gc_pauses,
|
|
960
|
+
gc_events: gc_raw.filter((g) => g.start >= perf_start && g.start <= perf_end).map((g) => ({
|
|
961
|
+
t: round2(g.start - perf_start),
|
|
962
|
+
ms: round2(g.ms),
|
|
963
|
+
kind: gc_kind(g.kind),
|
|
964
|
+
flags: g.flags
|
|
965
|
+
})),
|
|
966
|
+
gc_slices,
|
|
967
|
+
gc_dict,
|
|
968
|
+
...io_rec ? { promises: io_rec.promises() } : {},
|
|
969
|
+
io_ops: io_rec ? io_rec.stop() : [],
|
|
970
|
+
call_counts: {},
|
|
971
|
+
measures: summarize_measures(measures),
|
|
972
|
+
loop: {
|
|
973
|
+
p50: round2(histogram.percentile(50) / 1e6),
|
|
974
|
+
p99: round2(histogram.percentile(99) / 1e6),
|
|
975
|
+
max: round2(histogram.max / 1e6)
|
|
976
|
+
},
|
|
977
|
+
cpu_percent: duration_ms > 0 ? round2((cpu.user + cpu.system) / 1e3 / duration_ms * 100) : void 0,
|
|
978
|
+
elu_percent: round2(elu.utilization * 100),
|
|
979
|
+
t0,
|
|
980
|
+
t1,
|
|
981
|
+
duration_ms,
|
|
982
|
+
perf_start,
|
|
983
|
+
...heap_series.length >= 3 ? { heap_series: heap_series.filter((h) => h.t >= perf_start && h.t <= perf_end).map((h) => ({
|
|
984
|
+
t: round2(h.t - perf_start),
|
|
985
|
+
mb: h.mb
|
|
986
|
+
})) } : {}
|
|
987
|
+
};
|
|
988
|
+
} finally {
|
|
989
|
+
clearInterval(mem_timer);
|
|
990
|
+
if (heap_timer) clearInterval(heap_timer);
|
|
991
|
+
histogram.disable();
|
|
992
|
+
set_span_recorder(null);
|
|
993
|
+
span_als.disable();
|
|
994
|
+
set_request_stats_detail(false);
|
|
995
|
+
netmod?.set_stack_capture(false);
|
|
996
|
+
io_rec?.stop();
|
|
997
|
+
try {
|
|
998
|
+
observer?.disconnect();
|
|
999
|
+
} catch {}
|
|
1000
|
+
this.#window_net = null;
|
|
1001
|
+
try {
|
|
1002
|
+
session.disconnect();
|
|
1003
|
+
} catch {}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Exact per-function call counts (the ×N), from V8 precise coverage. Run as
|
|
1008
|
+
* its OWN render, not during the CPU sampling window — coverage disables
|
|
1009
|
+
* inlining, so mixing it into the sampled profile makes tiny hot functions
|
|
1010
|
+
* (svelte's `child`, `push`) look like the bottleneck. Counts are per this
|
|
1011
|
+
* single render.
|
|
1012
|
+
*/
|
|
1013
|
+
/** Call counts from one render under precise coverage. That render runs with V8's inlining off,
|
|
1014
|
+
* so a CPU profile taken during it (`with_profile`) keeps every app function in its own frame:
|
|
1015
|
+
* the report reads it for WHO a merged line's time belongs to (`unsmear`), never for how long
|
|
1016
|
+
* anything takes (no inlining makes everything slower). Same render, no extra one. */
|
|
1017
|
+
async #count_calls(work, with_profile = false) {
|
|
1018
|
+
const counts = {};
|
|
1019
|
+
let profile;
|
|
1020
|
+
try {
|
|
1021
|
+
const { Session } = await import("node:inspector/promises");
|
|
1022
|
+
const session = new Session();
|
|
1023
|
+
session.connect();
|
|
1024
|
+
try {
|
|
1025
|
+
await session.post("Profiler.enable");
|
|
1026
|
+
await session.post("Profiler.startPreciseCoverage", {
|
|
1027
|
+
callCount: true,
|
|
1028
|
+
detailed: false
|
|
1029
|
+
});
|
|
1030
|
+
if (with_profile) {
|
|
1031
|
+
await session.post("Profiler.setSamplingInterval", { interval: 200 });
|
|
1032
|
+
await session.post("Profiler.start");
|
|
1033
|
+
}
|
|
1034
|
+
try {
|
|
1035
|
+
await work();
|
|
1036
|
+
} finally {
|
|
1037
|
+
if (with_profile) try {
|
|
1038
|
+
profile = (await session.post("Profiler.stop")).profile;
|
|
1039
|
+
} catch {
|
|
1040
|
+
profile = void 0;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
const cov = await session.post("Profiler.takePreciseCoverage");
|
|
1044
|
+
await session.post("Profiler.stopPreciseCoverage");
|
|
1045
|
+
for (const script of cov.result) for (const fn of script.functions) {
|
|
1046
|
+
const nm = fn.functionName;
|
|
1047
|
+
if (!nm || nm.startsWith("(")) continue;
|
|
1048
|
+
const c = fn.ranges[0]?.count ?? 0;
|
|
1049
|
+
if (c > 0) {
|
|
1050
|
+
const k = nm + "\0" + script.url;
|
|
1051
|
+
counts[k] = (counts[k] ?? 0) + c;
|
|
1052
|
+
if (script.scriptId) {
|
|
1053
|
+
const ki = nm + "\0#" + script.scriptId;
|
|
1054
|
+
counts[ki] = (counts[ki] ?? 0) + c;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
} finally {
|
|
1059
|
+
session.disconnect();
|
|
1060
|
+
}
|
|
1061
|
+
} catch {}
|
|
1062
|
+
return {
|
|
1063
|
+
counts,
|
|
1064
|
+
...profile ? { profile } : {}
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
/** URL → local client file, for the browser CPU profile (see client-files.ts); undefined off Node */
|
|
1068
|
+
async #client_file_finder(origin) {
|
|
1069
|
+
try {
|
|
1070
|
+
const fs = await import("node:fs");
|
|
1071
|
+
const path = await import("node:path");
|
|
1072
|
+
const entry = process.argv[1] ? path.dirname(process.argv[1]) : void 0;
|
|
1073
|
+
const dirs = client_dir_candidates(entry, process.cwd(), path.join);
|
|
1074
|
+
return client_file_finder(origin, dirs, (p) => fs.existsSync(p), path.join);
|
|
1075
|
+
} catch {
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
/** THE MODULE MAP's word on a bundled server frame (no sourcemap needed): the profiler's own code,
|
|
1080
|
+
* ogygia's runtime or a bundled package, else nothing (the app's) — `analyze`'s `url_category` */
|
|
1081
|
+
#module_find = module_lookup();
|
|
1082
|
+
/** A BUILD WITHOUT SOURCEMAPS: a line of a built chunk named by the source module it came from
|
|
1083
|
+
* (the build's module map), so `chunks/format.js:131` reads as src/lib/format.ts */
|
|
1084
|
+
/** a position in a BUILT chunk that the module map files under the app (no sourcemap to resolve
|
|
1085
|
+
* it): the place to charge memory to, as the ledger names chunk lines. Undefined otherwise */
|
|
1086
|
+
#chunk_at(url, line) {
|
|
1087
|
+
if (!url || !line || line <= 0) return void 0;
|
|
1088
|
+
let path = url;
|
|
1089
|
+
if (path.startsWith("file://")) try {
|
|
1090
|
+
path = decodeURIComponent(new URL(path).pathname);
|
|
1091
|
+
} catch {
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
if (!may_be_chunk(path)) return void 0;
|
|
1095
|
+
const id = this.#module_find(path, line);
|
|
1096
|
+
return id && !module_category(id) ? {
|
|
1097
|
+
path,
|
|
1098
|
+
line
|
|
1099
|
+
} : void 0;
|
|
1100
|
+
}
|
|
1101
|
+
#label_module(x) {
|
|
1102
|
+
if (x.module || !may_be_chunk(x.path)) return;
|
|
1103
|
+
const id = this.#module_find(x.path, x.line);
|
|
1104
|
+
if (id && !module_category(id)) x.module = id;
|
|
1105
|
+
}
|
|
1106
|
+
#module_hint = (url, _name, line) => {
|
|
1107
|
+
const id = this.#module_find(url, line);
|
|
1108
|
+
return id ? module_category(id) : void 0;
|
|
1109
|
+
};
|
|
1110
|
+
async #make_resolver() {
|
|
1111
|
+
try {
|
|
1112
|
+
const fs = await import("node:fs");
|
|
1113
|
+
const path = await import("node:path");
|
|
1114
|
+
const entry = process.argv[1] ? path.dirname(process.argv[1]) : void 0;
|
|
1115
|
+
const bases = entry ? [
|
|
1116
|
+
entry,
|
|
1117
|
+
path.dirname(entry),
|
|
1118
|
+
"."
|
|
1119
|
+
] : ["."];
|
|
1120
|
+
const is_abs = (p) => p.startsWith("/") || WIN_DRIVE_RE.test(p);
|
|
1121
|
+
await load_embedded_maps();
|
|
1122
|
+
const dev_map = this.dev ? globalThis[Symbol.for("ogygia.profiler.dev-maps")] : void 0;
|
|
1123
|
+
const dev_client_map = this.dev ? globalThis[Symbol.for("ogygia.profiler.dev-client-maps")] : void 0;
|
|
1124
|
+
return sourcemap_resolver((p) => {
|
|
1125
|
+
if (dev_map && p.endsWith(".map")) {
|
|
1126
|
+
const m = dev_map(p.slice(0, -4));
|
|
1127
|
+
if (m) return m;
|
|
1128
|
+
}
|
|
1129
|
+
if (dev_client_map && p.startsWith("/") && p.endsWith(".map")) {
|
|
1130
|
+
const m = dev_client_map(p.slice(0, -4));
|
|
1131
|
+
if (m) return m;
|
|
1132
|
+
}
|
|
1133
|
+
for (const base of is_abs(p) ? [""] : bases) try {
|
|
1134
|
+
return fs.readFileSync(base ? path.join(base, p) : p, "utf8");
|
|
1135
|
+
} catch {}
|
|
1136
|
+
return p.endsWith(".map") ? embedded_map_of(p.slice(0, -4)) : embedded_source(p) ?? embedded_chunk(p);
|
|
1137
|
+
}, (chunk, line) => {
|
|
1138
|
+
const id = this.#module_find(chunk, line);
|
|
1139
|
+
if (!id || id.startsWith("\0") || id.startsWith("virtual:")) return void 0;
|
|
1140
|
+
if (id.startsWith("src/")) {
|
|
1141
|
+
if (!SCRIPT_MODULE_EXTS.some((e) => id.endsWith(e))) return void 0;
|
|
1142
|
+
return {
|
|
1143
|
+
source: path.resolve(process.cwd(), id),
|
|
1144
|
+
text: true
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
return {
|
|
1148
|
+
source: path.resolve(process.cwd(), id),
|
|
1149
|
+
text: false
|
|
1150
|
+
};
|
|
1151
|
+
}, { any_ext: !!dev_map });
|
|
1152
|
+
} catch {
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
async #finish_report(cap, meta_partial, fetch_url) {
|
|
1157
|
+
const own = `${this.base}/`;
|
|
1158
|
+
const path_of = (u) => {
|
|
1159
|
+
if (u.startsWith("/")) return u;
|
|
1160
|
+
try {
|
|
1161
|
+
return new URL(u).pathname;
|
|
1162
|
+
} catch {
|
|
1163
|
+
return u;
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
cap.net = cap.net.filter((c) => !path_of(c.url).startsWith(own));
|
|
1167
|
+
const resolver = await this.#make_resolver();
|
|
1168
|
+
const heap = cap.heap ? analyze_heap(cap.heap) : null;
|
|
1169
|
+
const heap_components = cap.heap ? heap_by_component(cap.heap) : null;
|
|
1170
|
+
const rel_src = (f) => {
|
|
1171
|
+
const clean = f.replace(QUERY_SUFFIX_RE, "");
|
|
1172
|
+
const m = SRC_RELATIVE_RE.exec(clean);
|
|
1173
|
+
if (m) return m[1].replace(BACKSLASH_G, "/");
|
|
1174
|
+
return clean.split(PATH_SEP_RE).slice(-3).join("/");
|
|
1175
|
+
};
|
|
1176
|
+
/** the source position a caller resolved to (absolute when the map gave one): what the
|
|
1177
|
+
* pattern finder reads the call site's code from */
|
|
1178
|
+
let last_at;
|
|
1179
|
+
const resolve_caller = (site) => {
|
|
1180
|
+
last_at = void 0;
|
|
1181
|
+
if (!site) return void 0;
|
|
1182
|
+
const name = site.fn && site.fn !== "(anonymous)" && !site.fn.includes(".") ? site.fn : void 0;
|
|
1183
|
+
let file = rel_src(site.file);
|
|
1184
|
+
let line = site.line;
|
|
1185
|
+
const m = resolver?.resolve(site.file, Math.max(0, site.line - 1), Math.max(0, site.column - 1));
|
|
1186
|
+
if (m) {
|
|
1187
|
+
file = rel_src(m.source);
|
|
1188
|
+
line = m.line;
|
|
1189
|
+
}
|
|
1190
|
+
if (file.startsWith("profiler/") || file.includes("/profiler/")) return void 0;
|
|
1191
|
+
if (WASM_FRAME_RE.test(file)) return void 0;
|
|
1192
|
+
if (FRAMEWORK_SOURCE_RE.test(file) || m && NODE_MODULES_RE.test(m.source)) return void 0;
|
|
1193
|
+
last_at = {
|
|
1194
|
+
path: m ? m.source : site.file,
|
|
1195
|
+
line
|
|
1196
|
+
};
|
|
1197
|
+
return name ? `${name} (${file}:${line})` : `${file}:${line}`;
|
|
1198
|
+
};
|
|
1199
|
+
for (const c of cap.net) {
|
|
1200
|
+
const ats = [];
|
|
1201
|
+
const chain = (c.caller_chain ?? (c.caller_site ? [c.caller_site] : [])).map((site) => {
|
|
1202
|
+
const text = resolve_caller(site);
|
|
1203
|
+
if (text && last_at && ats.length < 2) ats.push(last_at);
|
|
1204
|
+
return text;
|
|
1205
|
+
}).filter((s) => !!s);
|
|
1206
|
+
c.caller = chain[0] ?? c.caller;
|
|
1207
|
+
if (ats[0]) c.caller_at = ats[0];
|
|
1208
|
+
if (ats[1]) c.outer_at = ats[1];
|
|
1209
|
+
if (chain.length > 1) c.callers = chain;
|
|
1210
|
+
delete c.caller_site;
|
|
1211
|
+
delete c.caller_chain;
|
|
1212
|
+
}
|
|
1213
|
+
for (const o of cap.io_ops) {
|
|
1214
|
+
o.caller = resolve_caller(o.caller_site);
|
|
1215
|
+
if (o.caller && last_at) o.caller_at = last_at;
|
|
1216
|
+
delete o.caller_site;
|
|
1217
|
+
}
|
|
1218
|
+
for (const s of cap.spans) {
|
|
1219
|
+
s.caller = resolve_caller(s.caller_site);
|
|
1220
|
+
delete s.caller_site;
|
|
1221
|
+
}
|
|
1222
|
+
if (cap.promises) {
|
|
1223
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1224
|
+
for (const t of cap.promises.top) {
|
|
1225
|
+
const m = t.site ? resolver?.resolve(t.site.file, Math.max(0, t.site.line - 1), Math.max(0, t.site.column - 1)) : void 0;
|
|
1226
|
+
const src = m?.source ?? t.site?.file ?? "";
|
|
1227
|
+
const nm = src.lastIndexOf("/node_modules/");
|
|
1228
|
+
const fn = m?.name ?? t.site?.fn ?? "";
|
|
1229
|
+
const caller = !t.site ? t.caller : nm !== -1 ? `${fn} (${pkg_of(src.slice(nm + 14))})` : m ? `${fn} (${rel_src(m.source)}:${m.line})` : t.caller;
|
|
1230
|
+
merged.set(caller, (merged.get(caller) ?? 0) + t.share);
|
|
1231
|
+
}
|
|
1232
|
+
cap.promises = {
|
|
1233
|
+
count: cap.promises.count,
|
|
1234
|
+
top: [...merged].sort((a, b) => b[1] - a[1]).map(([caller, share]) => ({
|
|
1235
|
+
caller,
|
|
1236
|
+
share: Math.round(share * 100) / 100
|
|
1237
|
+
}))
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
const requests = this.#ring.filter((e) => e.ts + e.ms >= cap.t0 && e.ts <= cap.t1);
|
|
1241
|
+
const page_path = meta_partial.page?.split("?")[0];
|
|
1242
|
+
const own_route = meta_partial.trigger === "page" ? requests.find((e) => e.internal && e.path === page_path && e.route)?.route : meta_partial.request?.route;
|
|
1243
|
+
const timeline_input = cap.window ? {
|
|
1244
|
+
...this.#timeline_input(cap),
|
|
1245
|
+
...own_route ? { route: own_route } : {}
|
|
1246
|
+
} : void 0;
|
|
1247
|
+
const analysis = analyze(cap.profile, resolver, cap.call_counts, timeline_input, island_host_renamer(requests), this.#module_hint);
|
|
1248
|
+
const id = Math.random().toString(36).slice(2, 10);
|
|
1249
|
+
const meta = {
|
|
1250
|
+
id,
|
|
1251
|
+
created: cap.t0,
|
|
1252
|
+
duration_ms: round2(cap.duration_ms),
|
|
1253
|
+
sample_interval_us: this.sample_interval,
|
|
1254
|
+
requests,
|
|
1255
|
+
loop_delay: cap.loop,
|
|
1256
|
+
cpu_percent: cap.cpu_percent,
|
|
1257
|
+
elu_percent: cap.elu_percent,
|
|
1258
|
+
rss_mb: cap.mem.at(-1)?.rss,
|
|
1259
|
+
node: process.version,
|
|
1260
|
+
dev: this.dev || void 0,
|
|
1261
|
+
...meta_partial
|
|
1262
|
+
};
|
|
1263
|
+
let gc_attr;
|
|
1264
|
+
if (cap.gc_slices?.length && (cap.gc_events?.length || Object.keys(cap.gc_dict ?? {}).length)) try {
|
|
1265
|
+
if (resolver && cap.gc_dict) {
|
|
1266
|
+
const short = (u) => u.replace(/^file:\/\//, "").split("/").slice(-2).join("/");
|
|
1267
|
+
for (const site of Object.values(cap.gc_dict)) {
|
|
1268
|
+
const m = site.url ? resolver.resolve(site.url, site.line - 1, 0) : void 0;
|
|
1269
|
+
if (m) {
|
|
1270
|
+
site.url = m.source;
|
|
1271
|
+
site.line = m.line;
|
|
1272
|
+
const c = categorize({
|
|
1273
|
+
functionName: site.name,
|
|
1274
|
+
url: m.source,
|
|
1275
|
+
lineNumber: m.line - 1,
|
|
1276
|
+
columnNumber: 0,
|
|
1277
|
+
scriptId: ""
|
|
1278
|
+
});
|
|
1279
|
+
if (site.category !== "component" && c.category !== "component") site.category = c.category;
|
|
1280
|
+
}
|
|
1281
|
+
let caller_at;
|
|
1282
|
+
if (site.caller_url && site.caller_name) {
|
|
1283
|
+
const c = resolver.resolve(site.caller_url, (site.caller_line ?? 1) - 1, 0);
|
|
1284
|
+
if (c) {
|
|
1285
|
+
site.caller = `${site.caller_name} (${short(c.source)}:${c.line})`;
|
|
1286
|
+
caller_at = {
|
|
1287
|
+
path: c.source,
|
|
1288
|
+
line: c.line
|
|
1289
|
+
};
|
|
1290
|
+
if (site.category !== "component" && categorize({
|
|
1291
|
+
functionName: site.caller_name,
|
|
1292
|
+
url: c.source,
|
|
1293
|
+
lineNumber: 0,
|
|
1294
|
+
columnNumber: 0,
|
|
1295
|
+
scriptId: ""
|
|
1296
|
+
}).category === "profiler") site.category = "profiler";
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
if ((site.category === "app" || site.category === "component") && m) site.at = {
|
|
1300
|
+
path: m.source,
|
|
1301
|
+
line: m.line
|
|
1302
|
+
};
|
|
1303
|
+
else if (caller_at) site.at = caller_at;
|
|
1304
|
+
else if (!m) {
|
|
1305
|
+
const at = this.#chunk_at(site.url, site.line) ?? this.#chunk_at(site.caller_url, site.caller_line);
|
|
1306
|
+
if (at) site.at = at;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
const first = cap.mem[0]?.heap_used;
|
|
1311
|
+
const last = cap.mem[cap.mem.length - 1]?.heap_used;
|
|
1312
|
+
const window_offset_ms = cap.window ? round2(cap.window.start - cap.perf_start) : 0;
|
|
1313
|
+
gc_attr = attribute_gc({
|
|
1314
|
+
slices: cap.gc_slices,
|
|
1315
|
+
events: cap.gc_events ?? [],
|
|
1316
|
+
dict: cap.gc_dict ?? {},
|
|
1317
|
+
window_ms: cap.duration_ms,
|
|
1318
|
+
...analysis.capture_cpu ? { running: analysis.capture_cpu } : analysis.timeline ? { running: analysis.timeline.segments.filter((s) => s.kind === "cpu").map((s) => ({
|
|
1319
|
+
t0: s.t0 + window_offset_ms,
|
|
1320
|
+
t1: s.t1 + window_offset_ms,
|
|
1321
|
+
label: s.label,
|
|
1322
|
+
category: s.category,
|
|
1323
|
+
file: s.file
|
|
1324
|
+
})) } : {},
|
|
1325
|
+
...first !== void 0 && last !== void 0 ? { retained_mb: last - first } : {}
|
|
1326
|
+
});
|
|
1327
|
+
gc_attr.window_offset_ms = window_offset_ms;
|
|
1328
|
+
} catch (e) {
|
|
1329
|
+
if (process.env.OGYGIA_PROFILER_DEBUG) console.error("[ogygia/profiler] GC attribution failed:", e);
|
|
1330
|
+
gc_attr = void 0;
|
|
1331
|
+
}
|
|
1332
|
+
{
|
|
1333
|
+
const cpu_over = analysis.overhead_ms;
|
|
1334
|
+
const gc_over = gc_attr?.summary.overhead_ms ?? 0;
|
|
1335
|
+
const n = meta.runs?.length ?? 0;
|
|
1336
|
+
const per_run = Array.from({ length: n }, () => 0);
|
|
1337
|
+
const cpu_in_runs = analysis.overhead_by_run_ms ?? [];
|
|
1338
|
+
for (let i = 0; i < n; i++) per_run[i] += cpu_in_runs[i] ?? 0;
|
|
1339
|
+
if (gc_attr && cap.runs?.length) for (const p of gc_attr.pauses) {
|
|
1340
|
+
const t_abs = cap.perf_start + p.t;
|
|
1341
|
+
const i = cap.runs.findIndex((r) => t_abs >= r.start && t_abs <= r.end);
|
|
1342
|
+
if (i !== -1) per_run[i] += p.ms_measured - p.ms;
|
|
1343
|
+
}
|
|
1344
|
+
const in_runs = per_run.reduce((a, b) => a + b, 0);
|
|
1345
|
+
meta.overhead = {
|
|
1346
|
+
cpu_ms: round2(cpu_over),
|
|
1347
|
+
gc_ms: round2(gc_over),
|
|
1348
|
+
per_run_ms: n ? round2(in_runs / n) : 0,
|
|
1349
|
+
...n ? { per_run: per_run.map(round2) } : {},
|
|
1350
|
+
note: "the profiler’s own CPU frames and its share of the GC pauses, taken out of every number in this report: each run loses exactly what fell inside it; the start-up scan of compiled code and the final reads sit outside the runs. The sampler’s own thread and the inspector’s are not visible from inside and are not taken out."
|
|
1351
|
+
};
|
|
1352
|
+
if (n && in_runs > 0) {
|
|
1353
|
+
meta.runs_measured = [...meta.runs];
|
|
1354
|
+
meta.runs = meta.runs.map((r, i) => round2(Math.max(0, r - per_run[i])));
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
const gc = gc_attr ? {
|
|
1358
|
+
count: gc_attr.summary.count,
|
|
1359
|
+
total_ms: gc_attr.summary.total_ms,
|
|
1360
|
+
max_ms: gc_attr.summary.max_ms
|
|
1361
|
+
} : cap.gc_pauses.length ? {
|
|
1362
|
+
count: cap.gc_pauses.length,
|
|
1363
|
+
total_ms: round2(cap.gc_pauses.reduce((a, c) => a + c, 0)),
|
|
1364
|
+
max_ms: round2(Math.max(...cap.gc_pauses))
|
|
1365
|
+
} : null;
|
|
1366
|
+
const raw_json = JSON.stringify(cap.profile);
|
|
1367
|
+
let raw = raw_json;
|
|
1368
|
+
try {
|
|
1369
|
+
const { gzipSync } = await import("node:zlib");
|
|
1370
|
+
raw = gzipSync(raw_json);
|
|
1371
|
+
} catch {}
|
|
1372
|
+
let weights;
|
|
1373
|
+
let contents;
|
|
1374
|
+
let heavy;
|
|
1375
|
+
let barrels;
|
|
1376
|
+
let hazards;
|
|
1377
|
+
if (fetch_url && !this.dev) {
|
|
1378
|
+
const urls = [];
|
|
1379
|
+
for (const r of island_rows_of(meta)) urls.push(r.module_url, ...r.hints);
|
|
1380
|
+
if (urls.length) weights = await weigh_urls(urls, fetch_url, this.#weights);
|
|
1381
|
+
for (const u of new Set(urls)) {
|
|
1382
|
+
const inside = chunkContents(u);
|
|
1383
|
+
if (inside?.length) (contents ??= {})[u] = inside;
|
|
1384
|
+
const h = chunkHeavy(u);
|
|
1385
|
+
if (h?.top.length) (heavy ??= {})[u] = h;
|
|
1386
|
+
const b = chunkBarrels(u);
|
|
1387
|
+
if (b?.length) (barrels ??= {})[u] = b;
|
|
1388
|
+
}
|
|
1389
|
+
for (const r of island_rows_of(meta)) {
|
|
1390
|
+
const built = islandHazards(r.entry);
|
|
1391
|
+
if (built?.length) (hazards ??= {})[r.entry] = built;
|
|
1392
|
+
}
|
|
1393
|
+
if (contents) {
|
|
1394
|
+
const read = await this.#source_reader();
|
|
1395
|
+
for (const r of island_rows_of(meta)) {
|
|
1396
|
+
if (hazards?.[r.entry]) continue;
|
|
1397
|
+
const files = /* @__PURE__ */ new Set();
|
|
1398
|
+
for (const u of [r.module_url, ...r.hints]) for (const f of contents[u] ?? []) if (f.endsWith(".svelte") && f.startsWith("src/")) files.add(f);
|
|
1399
|
+
const found = [];
|
|
1400
|
+
for (const f of files) {
|
|
1401
|
+
const src = read(f.slice(4)) ?? read(f);
|
|
1402
|
+
if (src) for (const h of hydration_hazards(src).slice(0, 3)) found.push({
|
|
1403
|
+
file: f,
|
|
1404
|
+
...h
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
if (found.length) (hazards ??= {})[r.entry] = found.slice(0, 5);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
let alloc;
|
|
1412
|
+
if (cap.heap_series?.length) try {
|
|
1413
|
+
const window_offset_ms = cap.window ? round2(cap.window.start - cap.perf_start) : 0;
|
|
1414
|
+
alloc = alloc_timeline({
|
|
1415
|
+
samples: cap.heap_series,
|
|
1416
|
+
...analysis.capture_cpu ? { running: analysis.capture_cpu } : analysis.timeline ? { running: analysis.timeline.segments.filter((s) => s.kind === "cpu").map((s) => ({
|
|
1417
|
+
t0: s.t0 + window_offset_ms,
|
|
1418
|
+
t1: s.t1 + window_offset_ms,
|
|
1419
|
+
label: s.label,
|
|
1420
|
+
category: s.category,
|
|
1421
|
+
file: s.file
|
|
1422
|
+
})) } : {},
|
|
1423
|
+
gc: (cap.gc_events ?? []).map((g) => ({
|
|
1424
|
+
t: g.t,
|
|
1425
|
+
ms: g.ms
|
|
1426
|
+
})),
|
|
1427
|
+
...cap.window ? { window: {
|
|
1428
|
+
offset_ms: window_offset_ms,
|
|
1429
|
+
ms: round2(cap.window.end - cap.window.start)
|
|
1430
|
+
} } : {}
|
|
1431
|
+
});
|
|
1432
|
+
} catch {
|
|
1433
|
+
alloc = void 0;
|
|
1434
|
+
}
|
|
1435
|
+
let contended;
|
|
1436
|
+
{
|
|
1437
|
+
const windows = cap.runs?.length ? cap.runs : cap.window ? [cap.window] : [];
|
|
1438
|
+
if (windows.length) try {
|
|
1439
|
+
const self_paths = /* @__PURE__ */ new Set();
|
|
1440
|
+
for (const c of cap.net) {
|
|
1441
|
+
const at = c.url.indexOf("://");
|
|
1442
|
+
const rest = at === -1 ? c.url : c.url.slice(c.url.indexOf("/", at + 3));
|
|
1443
|
+
const q = rest.indexOf("?");
|
|
1444
|
+
self_paths.add(q === -1 ? rest : rest.slice(0, q));
|
|
1445
|
+
}
|
|
1446
|
+
contended = contention({
|
|
1447
|
+
requests: this.#ring,
|
|
1448
|
+
windows,
|
|
1449
|
+
own: this.#ring.filter((e) => e.internal && e.pt !== void 0 && windows.some((w) => e.pt >= w.start - 1 && e.pt <= w.end)),
|
|
1450
|
+
self_paths
|
|
1451
|
+
});
|
|
1452
|
+
} catch {
|
|
1453
|
+
contended = void 0;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
this.#reports.set(id, {
|
|
1457
|
+
meta,
|
|
1458
|
+
analysis,
|
|
1459
|
+
heap,
|
|
1460
|
+
heap_components,
|
|
1461
|
+
net: cap.net,
|
|
1462
|
+
spans: cap.spans,
|
|
1463
|
+
mem: cap.mem,
|
|
1464
|
+
measures: cap.measures,
|
|
1465
|
+
gc,
|
|
1466
|
+
io: cap.io_ops,
|
|
1467
|
+
...cap.promises ? { promises: cap.promises } : {},
|
|
1468
|
+
call_counts: cap.call_counts,
|
|
1469
|
+
raw,
|
|
1470
|
+
...weights ? { weights } : {},
|
|
1471
|
+
...contents ? { contents } : {},
|
|
1472
|
+
...heavy ? { heavy } : {},
|
|
1473
|
+
...barrels ? { barrels } : {},
|
|
1474
|
+
...hazards ? { hazards } : {},
|
|
1475
|
+
...gc_attr ? { gc_attr } : {},
|
|
1476
|
+
...alloc ? { alloc } : {},
|
|
1477
|
+
...contended ? { contention: contended } : {}
|
|
1478
|
+
});
|
|
1479
|
+
if (meta_partial.trigger !== "page") {
|
|
1480
|
+
const s = this.#reports.get(id);
|
|
1481
|
+
try {
|
|
1482
|
+
await this.#lines_for(s, void 0, cap.window?.start);
|
|
1483
|
+
} catch {}
|
|
1484
|
+
}
|
|
1485
|
+
if (meta_partial.trigger !== "page") this.#persist(this.#reports.get(id));
|
|
1486
|
+
while (this.#reports.size > this.max_reports) {
|
|
1487
|
+
const oldest = this.#reports.keys().next().value;
|
|
1488
|
+
if (oldest === void 0) break;
|
|
1489
|
+
this.#reports.delete(oldest);
|
|
1490
|
+
}
|
|
1491
|
+
return id;
|
|
1492
|
+
}
|
|
1493
|
+
async #key_matches(provided) {
|
|
1494
|
+
if (this.dev) return true;
|
|
1495
|
+
if (!this.secret || !provided) return false;
|
|
1496
|
+
try {
|
|
1497
|
+
const { createHash, timingSafeEqual } = await import("node:crypto");
|
|
1498
|
+
const h = (str) => createHash("sha256").update(str).digest();
|
|
1499
|
+
return timingSafeEqual(h(provided), h(this.secret)) || timingSafeEqual(h(provided), h(this.#cookie_token(createHash)));
|
|
1500
|
+
} catch {
|
|
1501
|
+
return provided === this.secret;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
#cookie_token(createHash) {
|
|
1505
|
+
return createHash("sha256").update("og-profiler:" + this.secret).digest("hex");
|
|
1506
|
+
}
|
|
1507
|
+
/** Cookie header that starts / clears a profiler session (Set-Cookie value). */
|
|
1508
|
+
#session_cookie(token, event) {
|
|
1509
|
+
const secure = event.url.protocol === "https:" ? "; Secure" : "";
|
|
1510
|
+
const clear = token === "" ? "; Max-Age=0" : "";
|
|
1511
|
+
const age = token === "" ? "" : "; Max-Age=31536000";
|
|
1512
|
+
return `${SESSION_COOKIE}=${token}; Path=${this.base}; HttpOnly; SameSite=Lax${secure}${clear}${age}`;
|
|
1513
|
+
}
|
|
1514
|
+
/** The BEACON FLAG: a second cookie, site-wide, carrying no secret — it only tells the handle
|
|
1515
|
+
* "this browser is the profiler's user, put the beacon tag in its documents". The session
|
|
1516
|
+
* cookie stays scoped to the profiler's own path (the `/beacon` POST is under it, so the
|
|
1517
|
+
* runtime's post still carries the real session). Set with the session, cleared with it. */
|
|
1518
|
+
#beacon_cookie(on, event) {
|
|
1519
|
+
const secure = event.url.protocol === "https:" ? "; Secure" : "";
|
|
1520
|
+
return `og_profiler_beacon=${on ? "1" : ""}; Path=/; HttpOnly; SameSite=Lax${secure}${on ? "" : "; Max-Age=0"}`;
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Validate + set up a session. The unlock page POSTs the key here; a match sets the session cookie
|
|
1524
|
+
* and returns to `next`. No HTTP Basic dialog — a plain form + cookie, so the `Authorization` header
|
|
1525
|
+
* is never spent on profiler auth (it stays free, and there's a real Logout).
|
|
1526
|
+
*/
|
|
1527
|
+
async #login(ctx) {
|
|
1528
|
+
const body = await ctx.request.json().catch(() => null);
|
|
1529
|
+
const key = String(body?.key ?? "");
|
|
1530
|
+
const next = this.#safe_next(String(body?.next ?? this.base));
|
|
1531
|
+
if (!await this.#key_matches(key)) return ctx.json({ ok: false }, { status: 401 });
|
|
1532
|
+
const { createHash } = await import("node:crypto");
|
|
1533
|
+
const res = ctx.json({
|
|
1534
|
+
ok: true,
|
|
1535
|
+
next: next + (next.includes("?") ? "&" : "?") + AFTER_LOGIN_PARAM + "=1"
|
|
1536
|
+
});
|
|
1537
|
+
res.headers.set("cache-control", "no-store, private");
|
|
1538
|
+
res.headers.append("set-cookie", this.#session_cookie(this.#cookie_token(createHash), ctx.event));
|
|
1539
|
+
return res;
|
|
1540
|
+
}
|
|
1541
|
+
/** false = denied; true = authed; string = authed AND set this cookie */
|
|
1542
|
+
async #authed(event) {
|
|
1543
|
+
if (!this.ui_enabled) return false;
|
|
1544
|
+
if (this.dev) return true;
|
|
1545
|
+
for (const value of session_cookie_values(event)) if (await this.#key_matches(value)) return true;
|
|
1546
|
+
return this.#key_matches(event.request.headers.get("x-profiler-key"));
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* What a request that failed auth carried, for a browser that loops on login — no cookie values,
|
|
1550
|
+
* only facts: did a Cookie header arrive, how big, how many `og_profiler` pairs were in it, and
|
|
1551
|
+
* did Kit's own parser find one. 0 pairs with a header = the cookie never reached the server
|
|
1552
|
+
* (dropped on the way, or the jar is past a proxy's limit); pairs but no parse = a parser problem;
|
|
1553
|
+
* more than one pair = duplicates, one of them stale.
|
|
1554
|
+
*/
|
|
1555
|
+
#cookie_diagnosis(event) {
|
|
1556
|
+
const raw = event.request.headers.get("cookie");
|
|
1557
|
+
return {
|
|
1558
|
+
header: raw != null,
|
|
1559
|
+
bytes: raw?.length ?? 0,
|
|
1560
|
+
pairs: raw ? raw_cookie_values(raw, SESSION_COOKIE).length : 0,
|
|
1561
|
+
parsed: event.cookies.get(SESSION_COOKIE) != null
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
async #ui(event) {
|
|
1565
|
+
const res = await this.#router().fetch(event.request, event) ?? new Response("Not found", { status: 404 });
|
|
1566
|
+
return this.#flag_cookie(event, await gzip_large(event.request, res));
|
|
1567
|
+
}
|
|
1568
|
+
/** THE BEACON FLAG follows the session, one response later: set on the first response to a
|
|
1569
|
+
* browser whose session cookie is good and that lacks the flag, cleared on one to a browser with
|
|
1570
|
+
* the flag and no session. Never beside another Set-Cookie (the login's, the logout's): a host
|
|
1571
|
+
* that keeps one per response must not lose the session for it. */
|
|
1572
|
+
async #flag_cookie(event, res) {
|
|
1573
|
+
if (this.dev || !this.secret || res.headers.has("set-cookie")) return res;
|
|
1574
|
+
const flag = event.cookies.get("og_profiler_beacon") === "1";
|
|
1575
|
+
const values = session_cookie_values(event);
|
|
1576
|
+
let session = false;
|
|
1577
|
+
for (const v of values) if (await this.#key_matches(v)) session = true;
|
|
1578
|
+
if (session === flag) return res;
|
|
1579
|
+
const out = new Response(res.body, res);
|
|
1580
|
+
out.headers.append("set-cookie", this.#beacon_cookie(session, event));
|
|
1581
|
+
return out;
|
|
1582
|
+
}
|
|
1583
|
+
async #auth_guard(ctx) {
|
|
1584
|
+
const rel = ctx.url.pathname.slice(this.base.length).replace(TRAILING_SLASH_RE, "") || "/";
|
|
1585
|
+
if (rel === "/login" || rel === "/logout" || /^\/report\/[^/.]+$/.test(rel)) return;
|
|
1586
|
+
if (await this.#authed(ctx.event)) return;
|
|
1587
|
+
if (this.ui_enabled && this.secret && !this.dev) {
|
|
1588
|
+
const after_login = ctx.url.searchParams.has(AFTER_LOGIN_PARAM);
|
|
1589
|
+
const diag = this.#cookie_diagnosis(ctx.event);
|
|
1590
|
+
if (after_login || diag.pairs > 0) console.warn(`[ogygia profiler] session not accepted on ${ctx.url.pathname}: ${JSON.stringify(diag)} — ${describe_cookie_diagnosis(diag)}`);
|
|
1591
|
+
const target = new URL(ctx.url);
|
|
1592
|
+
target.searchParams.delete(AFTER_LOGIN_PARAM);
|
|
1593
|
+
const next = encodeURIComponent(target.pathname + target.search);
|
|
1594
|
+
return ctx.redirect(`${this.base}/login?next=${next}${after_login ? `&${LOGIN_FAILED_PARAM}=1` : ""}`);
|
|
1595
|
+
}
|
|
1596
|
+
return new Response("Not found", { status: 404 });
|
|
1597
|
+
}
|
|
1598
|
+
#routes;
|
|
1599
|
+
#router() {
|
|
1600
|
+
return this.#routes ??= build_profiler_router({
|
|
1601
|
+
base: this.base,
|
|
1602
|
+
auth_guard: (c) => this.#auth_guard(c),
|
|
1603
|
+
authed: (c) => this.#authed(c.event),
|
|
1604
|
+
beacon: (c) => this.#beacon(c),
|
|
1605
|
+
replay: (id, c) => this.#replay(id, c),
|
|
1606
|
+
dashboard: (c) => this.#dashboard(c.url.searchParams.get("by")),
|
|
1607
|
+
run_page: (c) => this.#run_page(c),
|
|
1608
|
+
record_page: (c) => this.#record_page(c),
|
|
1609
|
+
window_start: (c) => this.#window_start(c),
|
|
1610
|
+
window_stop: (c) => this.#window_stop(c),
|
|
1611
|
+
reset: (c) => this.#reset(c),
|
|
1612
|
+
status: () => ({
|
|
1613
|
+
recording: this.#recorder_busy || this.#recording_active(),
|
|
1614
|
+
inflight: this.#inflight,
|
|
1615
|
+
rss_mb: Math.round(process.memoryUsage().rss / 1048576),
|
|
1616
|
+
reports: this.#reports.size
|
|
1617
|
+
}),
|
|
1618
|
+
login_props: (c) => ({
|
|
1619
|
+
base: this.base,
|
|
1620
|
+
next: this.#safe_next(c.url.searchParams.get("next")),
|
|
1621
|
+
session_problem: c.url.searchParams.has(LOGIN_FAILED_PARAM) ? describe_cookie_diagnosis(this.#cookie_diagnosis(c.event)) : null
|
|
1622
|
+
}),
|
|
1623
|
+
login: (c) => this.#login(c),
|
|
1624
|
+
logout: (c) => this.#logout(c),
|
|
1625
|
+
upload: (c) => this.#upload(c),
|
|
1626
|
+
report_stored: (id) => this.#reports.get(id ?? ""),
|
|
1627
|
+
report_load: (id) => this.#report_load(id),
|
|
1628
|
+
list_stored: (limit) => this.#list_stored(limit),
|
|
1629
|
+
login_url: (c) => this.ui_enabled && this.secret && !this.dev ? `${this.base}/login?next=${encodeURIComponent(c.url.pathname)}` : null,
|
|
1630
|
+
report_view: (stored) => this.#report_view(stored),
|
|
1631
|
+
report_json: (stored) => this.#report_json(stored),
|
|
1632
|
+
report_dump_json: (stored) => this.#report_dump_json(stored),
|
|
1633
|
+
report_html: (stored, c) => this.#report_html(stored, c),
|
|
1634
|
+
report_posted: (c, as) => this.#report_posted(c, as),
|
|
1635
|
+
report_raw: (stored) => this.#report_raw(stored),
|
|
1636
|
+
compare: (a, b) => this.#compare(a, b),
|
|
1637
|
+
site: (c) => this.#site(c),
|
|
1638
|
+
source: (c) => this.#source(c)
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* THE TIMELINE INPUT: the one request's window + every call it waited on. Net calls as they
|
|
1643
|
+
* are. I/O primitives only when they are the app's own wait — the same rule as the "Waiting by
|
|
1644
|
+
* function" table: the op must have a resolved app caller (an unattributed one is undici's
|
|
1645
|
+
* socket timer, a response compressor's zlib stream, the recorder's own timer; a WASM caller
|
|
1646
|
+
* is a library's internals) — and an op that sits inside a net call's span is the other end of
|
|
1647
|
+
* that same call (an in-process API's timer on a self-fetch); the net call already says it.
|
|
1648
|
+
* Each call carries the phase of the code that started it, from the caller's file.
|
|
1649
|
+
*/
|
|
1650
|
+
#timeline_input(cap) {
|
|
1651
|
+
const w = cap.window;
|
|
1652
|
+
const phase_of = (caller) => {
|
|
1653
|
+
const m = caller ? CALLER_FILE_RE.exec(caller) : null;
|
|
1654
|
+
return m ? phase_of_frame({
|
|
1655
|
+
name: "",
|
|
1656
|
+
url: m[1],
|
|
1657
|
+
line: 0,
|
|
1658
|
+
category: "app"
|
|
1659
|
+
}) ?? void 0 : void 0;
|
|
1660
|
+
};
|
|
1661
|
+
const net = cap.net.filter((c) => c.ms >= 0).map((c) => ({
|
|
1662
|
+
start: c.start,
|
|
1663
|
+
end: c.start + c.ms + (c.body_ms ?? 0),
|
|
1664
|
+
c
|
|
1665
|
+
}));
|
|
1666
|
+
const inside_a_call = (s, e) => net.some((n) => s >= n.start - 2 && e <= n.end + 2);
|
|
1667
|
+
const overlaps_a_call = (s, e) => net.some((n) => s < n.end && e > n.start);
|
|
1668
|
+
const is_handler_side = (o) => !!o.caller && SERVER_ROUTE_FILE_RE.test(o.caller) && overlaps_a_call(o.start, o.start + o.ms);
|
|
1669
|
+
return {
|
|
1670
|
+
perf_start: cap.perf_start,
|
|
1671
|
+
window: w,
|
|
1672
|
+
...cap.runs ? { runs: cap.runs } : {},
|
|
1673
|
+
...cap.set_aside?.length ? { set_aside: cap.set_aside } : {},
|
|
1674
|
+
pending: cap.io_ops.filter((o) => o.start <= w.end && o.start + Math.max(o.ms, 0) >= w.start).map((o) => ({
|
|
1675
|
+
start: o.start,
|
|
1676
|
+
end: o.open ? w.end : o.start + o.ms,
|
|
1677
|
+
label: o.caller ? `${o.type} from ${o.caller}` : o.type,
|
|
1678
|
+
kind: io_kind(o.type)
|
|
1679
|
+
})),
|
|
1680
|
+
calls: [
|
|
1681
|
+
...cap.spans.filter((s) => !s.open && s.ms >= 0).map((s) => ({
|
|
1682
|
+
start: s.start,
|
|
1683
|
+
ms: s.ms,
|
|
1684
|
+
label: s.name,
|
|
1685
|
+
kind: "span",
|
|
1686
|
+
caller: s.caller,
|
|
1687
|
+
phase: phase_of(s.caller)
|
|
1688
|
+
})),
|
|
1689
|
+
...cap.net.map((c) => ({
|
|
1690
|
+
start: c.start,
|
|
1691
|
+
ms: c.ms < 0 ? -1 : c.ms + (c.body_ms ?? 0),
|
|
1692
|
+
label: label_call(c.method, c.url),
|
|
1693
|
+
kind: "net",
|
|
1694
|
+
caller: c.caller,
|
|
1695
|
+
phase: phase_of(c.caller),
|
|
1696
|
+
...c.callers ? { callers: c.callers } : {},
|
|
1697
|
+
...c.timings ? { timings: c.timings } : {},
|
|
1698
|
+
...c.ms >= 0 && c.body_ms ? { body_ms: c.body_ms } : {}
|
|
1699
|
+
})),
|
|
1700
|
+
...cap.io_ops.filter((o) => !o.open && o.ms >= .5 && o.type !== "Immediate" && o.type !== "TickObject" && !!o.caller && !o.caller.startsWith("/wasm/") && !o.caller.includes("(/wasm/") && !inside_a_call(o.start, o.start + o.ms) && !is_handler_side(o)).map((o) => ({
|
|
1701
|
+
start: o.start,
|
|
1702
|
+
ms: o.ms,
|
|
1703
|
+
label: `${io_kind(o.type)}${o.caller ? ` · ${o.caller}` : ""}`,
|
|
1704
|
+
kind: io_kind(o.type),
|
|
1705
|
+
caller: o.caller,
|
|
1706
|
+
phase: phase_of(o.caller)
|
|
1707
|
+
}))
|
|
1708
|
+
]
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
/**
|
|
1712
|
+
* The report as ONE self-contained HTML file (profiler/standalone.ts): the page is rendered
|
|
1713
|
+
* through this very server (a self-request, marked internal so it is not logged as traffic),
|
|
1714
|
+
* then every stylesheet, the runtime and every island chunk it reaches are fetched the same way
|
|
1715
|
+
* and inlined. Needs a BUILT app: a dev server's module graph is Vite's, hundreds of unbundled
|
|
1716
|
+
* files with transforms — not a thing one file can carry.
|
|
1717
|
+
*/
|
|
1718
|
+
async #report_html(stored, ctx) {
|
|
1719
|
+
if (this.dev) return new Response("The standalone HTML export needs a built app (vite build + preview, or your production deploy): on the dev server the page’s code is hundreds of unbundled modules. Export the .ogp here and open it on a built server, or run the export there.", {
|
|
1720
|
+
status: 400,
|
|
1721
|
+
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
1722
|
+
});
|
|
1723
|
+
const page_url = `${ctx.url.origin}${this.base}/report/${stored.meta.id}`;
|
|
1724
|
+
const headers = { "x-og-profiler-internal": "1" };
|
|
1725
|
+
if (this.secret) headers["x-profiler-key"] = this.secret;
|
|
1726
|
+
const load = async (url) => {
|
|
1727
|
+
for (const f of ctx.fetch === fetch ? [fetch] : [ctx.fetch, fetch]) try {
|
|
1728
|
+
const res = await f(url, { headers });
|
|
1729
|
+
if (res.ok) return await res.text();
|
|
1730
|
+
} catch {}
|
|
1731
|
+
return null;
|
|
1732
|
+
};
|
|
1733
|
+
const html = await load(page_url);
|
|
1734
|
+
if (html === null) return new Response("Could not render the report page.", { status: 502 });
|
|
1735
|
+
const { build_standalone } = await import("./standalone.js");
|
|
1736
|
+
try {
|
|
1737
|
+
const out = await build_standalone(html, {
|
|
1738
|
+
page_url,
|
|
1739
|
+
load
|
|
1740
|
+
});
|
|
1741
|
+
return new Response(out, { headers: {
|
|
1742
|
+
"content-type": "text/html; charset=utf-8",
|
|
1743
|
+
"content-disposition": `attachment; filename="ogygia-profile-${stored.meta.id}.html"`,
|
|
1744
|
+
"cache-control": "no-store"
|
|
1745
|
+
} });
|
|
1746
|
+
} catch (e) {
|
|
1747
|
+
return new Response(e instanceof Error ? e.message : "standalone export failed", { status: 500 });
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
/** A report's representations (the standalone file, the agent JSON, the plain dump) from a report
|
|
1751
|
+
* the request carries: the page's .ogp bytes or the browser's kept copy. On a serverless host the
|
|
1752
|
+
* instance that made the report is rarely the one a later request lands on; with the report in
|
|
1753
|
+
* the request, any instance answers. It is held in this instance's memory under its own id, so
|
|
1754
|
+
* the in-process render of the report page (the standalone file) finds it too. */
|
|
1755
|
+
async #report_posted(ctx, as) {
|
|
1756
|
+
const id = ctx.params.id;
|
|
1757
|
+
if (!id) return new Response("No report id.", { status: 400 });
|
|
1758
|
+
let dump;
|
|
1759
|
+
try {
|
|
1760
|
+
const raw = new Uint8Array(await ctx.request.arrayBuffer());
|
|
1761
|
+
if ((ctx.request.headers.get("content-type") ?? "").startsWith("application/octet-stream")) {
|
|
1762
|
+
const bytes = recover_ogp_bytes(raw);
|
|
1763
|
+
if (!is_ogp(bytes)) return new Response("That is not an ogygia .ogp report.", { status: 400 });
|
|
1764
|
+
dump = await ogp_decode(bytes, this.secret);
|
|
1765
|
+
} else if (ctx.request.headers.get("x-og-encoding") === "gzip") {
|
|
1766
|
+
const { gunzipSync } = await import("node:zlib");
|
|
1767
|
+
dump = JSON.parse(new TextDecoder().decode(gunzipSync(raw)));
|
|
1768
|
+
} else dump = JSON.parse(new TextDecoder().decode(raw));
|
|
1769
|
+
} catch {
|
|
1770
|
+
return new Response("The request carried no report this server can open.", { status: 400 });
|
|
1771
|
+
}
|
|
1772
|
+
if (!is_dump(dump)) return new Response("That is not an ogygia profiler report.", { status: 400 });
|
|
1773
|
+
let stored = this.#reports.get(id);
|
|
1774
|
+
if (!stored) {
|
|
1775
|
+
stored = this.#report_from_dump({
|
|
1776
|
+
...dump,
|
|
1777
|
+
meta: {
|
|
1778
|
+
...dump.meta,
|
|
1779
|
+
id
|
|
1780
|
+
}
|
|
1781
|
+
});
|
|
1782
|
+
this.#reports.set(id, stored);
|
|
1783
|
+
while (this.#reports.size > this.max_reports) {
|
|
1784
|
+
const oldest = this.#reports.keys().next().value;
|
|
1785
|
+
if (oldest === void 0) break;
|
|
1786
|
+
this.#reports.delete(oldest);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
if (as === "html") return this.#report_html(stored, ctx);
|
|
1790
|
+
if (as === "json") return this.#report_json(stored);
|
|
1791
|
+
if (as === "ogp") return this.#ogp_response(id, stored);
|
|
1792
|
+
return this.#report_dump_json(stored);
|
|
1793
|
+
}
|
|
1794
|
+
/** Two stored reports side by side (compare.ts); a 404 when either has expired. */
|
|
1795
|
+
/** THE DATA RIVER for a page report: the last run's calls on their load lanes, what each load's
|
|
1796
|
+
* source returns, what each island reads (the build's page keys), the seed's bytes per key. */
|
|
1797
|
+
async #river_for(stored, body, last_window) {
|
|
1798
|
+
const tl = stored.analysis.timeline;
|
|
1799
|
+
if (!tl) return void 0;
|
|
1800
|
+
const lanes = tl.lanes ?? [];
|
|
1801
|
+
if (!lanes.length && !stored.net.length) return void 0;
|
|
1802
|
+
const read = await this.#source_reader();
|
|
1803
|
+
const lane_files = [...new Set(lanes.map((l) => l.file))];
|
|
1804
|
+
const river_lanes = lane_files.map((file) => {
|
|
1805
|
+
const src = read(file);
|
|
1806
|
+
return {
|
|
1807
|
+
file,
|
|
1808
|
+
keys: src ? load_return_keys(src) : null
|
|
1809
|
+
};
|
|
1810
|
+
});
|
|
1811
|
+
const calls = stored.net.filter((c) => !last_window || c.start >= last_window.start && c.start <= last_window.end).map((c) => {
|
|
1812
|
+
const text = [
|
|
1813
|
+
c.caller_site?.file ?? "",
|
|
1814
|
+
...c.callers ?? [],
|
|
1815
|
+
c.caller ?? ""
|
|
1816
|
+
].join(" ");
|
|
1817
|
+
const lane = load_lane_of(text)?.file ?? null;
|
|
1818
|
+
return {
|
|
1819
|
+
lane: lane && lane_files.includes(lane) ? lane : lane_files.find((f) => lane && (f.endsWith(lane) || lane.endsWith(f))) ?? null,
|
|
1820
|
+
label: `${c.method} ${c.url}`,
|
|
1821
|
+
ms: Math.max(c.ms, 0) + (c.body_ms ?? 0)
|
|
1822
|
+
};
|
|
1823
|
+
});
|
|
1824
|
+
const by_entry = /* @__PURE__ */ new Map();
|
|
1825
|
+
for (const r of island_rows_of(stored.meta)) {
|
|
1826
|
+
if (by_entry.has(r.entry)) continue;
|
|
1827
|
+
let keys = null;
|
|
1828
|
+
const dev = await this.#dev_island_keys(r.entry);
|
|
1829
|
+
if (dev) keys = dev.keys === "all" ? null : dev.keys ?? [];
|
|
1830
|
+
else try {
|
|
1831
|
+
keys = islandPageKeys(r.entry);
|
|
1832
|
+
} catch {
|
|
1833
|
+
keys = null;
|
|
1834
|
+
}
|
|
1835
|
+
by_entry.set(r.entry, {
|
|
1836
|
+
name: island_name(r),
|
|
1837
|
+
keys
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
const seed_m = SEED_SCRIPT_RE.exec(body);
|
|
1841
|
+
const seed = seed_m ? seed_key_bytes(seed_m[1]) : {};
|
|
1842
|
+
if (!calls.length && !Object.keys(seed).length && !by_entry.size) return void 0;
|
|
1843
|
+
return build_river({
|
|
1844
|
+
calls,
|
|
1845
|
+
lanes: river_lanes,
|
|
1846
|
+
islands: [...by_entry.values()],
|
|
1847
|
+
seed
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
/** THE LINE LEDGER (ledger.ts): the app lines that cost the most — CPU per line, the bytes each
|
|
1851
|
+
* allocated and the GC that caused, what a render kept — joined per line, with the code read
|
|
1852
|
+
* from the source map's embedded copy (so it shows on a deployed host). Then THE PATTERNS
|
|
1853
|
+
* (patterns.ts): the known slow shapes recognised on those lines. Both land on the report. */
|
|
1854
|
+
/** `w0`: the profiled window's start (performance.now()), the clock the timeline's await graph
|
|
1855
|
+
* counts from; the waits' critical-path check lines the chains up on it */
|
|
1856
|
+
async #lines_for(s, uninlined_profile, w0, stable, wait_runs, almost) {
|
|
1857
|
+
const resolver = await this.#make_resolver();
|
|
1858
|
+
let uninlined;
|
|
1859
|
+
if (uninlined_profile) try {
|
|
1860
|
+
uninlined = analyze(uninlined_profile, resolver, void 0, void 0, void 0, this.#module_hint).functions;
|
|
1861
|
+
} catch {
|
|
1862
|
+
uninlined = void 0;
|
|
1863
|
+
}
|
|
1864
|
+
const fns = /* @__PURE__ */ new Map();
|
|
1865
|
+
for (const f of s.analysis.functions) fns.set(f.key, f);
|
|
1866
|
+
for (const c of s.analysis.components) {
|
|
1867
|
+
const e = fns.get(c.key);
|
|
1868
|
+
if (!e || !e.lines?.length && c.lines?.length) fns.set(c.key, c);
|
|
1869
|
+
}
|
|
1870
|
+
const ledger = build_ledger({
|
|
1871
|
+
functions: [...fns.values()],
|
|
1872
|
+
makers: s.gc_attr?.makers,
|
|
1873
|
+
retained: s.retained?.sites,
|
|
1874
|
+
...s.growth?.sites ? { grown: {
|
|
1875
|
+
renders: s.growth.renders,
|
|
1876
|
+
sites: s.growth.sites,
|
|
1877
|
+
...s.growth.half_sites && s.growth.half_renders ? { half: {
|
|
1878
|
+
renders: s.growth.half_renders,
|
|
1879
|
+
sites: s.growth.half_sites
|
|
1880
|
+
} } : {}
|
|
1881
|
+
} } : {},
|
|
1882
|
+
code: (p, l) => resolver?.source_lines(p, l, l)?.lines[0],
|
|
1883
|
+
source: resolver ? (p, a, b) => resolver.source_lines(p, a, b) : void 0,
|
|
1884
|
+
...uninlined ? {
|
|
1885
|
+
uninlined,
|
|
1886
|
+
renders: s.meta.trigger === "page" ? s.meta.runs?.length || 1 : 1
|
|
1887
|
+
} : {},
|
|
1888
|
+
limit: LEDGER_FOR_PATTERNS
|
|
1889
|
+
});
|
|
1890
|
+
const source = resolver ? (p, a, b) => resolver.source_lines(p, a, b) : void 0;
|
|
1891
|
+
for (const l of ledger) this.#label_module(l);
|
|
1892
|
+
if (ledger.length) s.ledger = ledger.slice(0, LEDGER_SHOWN);
|
|
1893
|
+
const renders = s.meta.trigger === "page" ? s.meta.runs?.length || 1 : 1;
|
|
1894
|
+
let heap;
|
|
1895
|
+
try {
|
|
1896
|
+
const st = (await import("node:v8")).getHeapStatistics();
|
|
1897
|
+
heap = {
|
|
1898
|
+
limit_bytes: st.heap_size_limit,
|
|
1899
|
+
used_bytes: st.used_heap_size
|
|
1900
|
+
};
|
|
1901
|
+
const fn_mb = Number(process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE);
|
|
1902
|
+
if (fn_mb > 0 && fn_mb * 1024 * 1024 < heap.limit_bytes) heap = {
|
|
1903
|
+
limit_bytes: fn_mb * 1024 * 1024,
|
|
1904
|
+
used_bytes: process.memoryUsage().rss,
|
|
1905
|
+
function: true
|
|
1906
|
+
};
|
|
1907
|
+
} catch {}
|
|
1908
|
+
const patterns = ledger.length ? find_patterns({
|
|
1909
|
+
ledger,
|
|
1910
|
+
functions: [...fns.values()],
|
|
1911
|
+
source,
|
|
1912
|
+
renders,
|
|
1913
|
+
heap,
|
|
1914
|
+
growth: s.growth
|
|
1915
|
+
}) : [];
|
|
1916
|
+
const calls = [];
|
|
1917
|
+
for (const c of s.net) {
|
|
1918
|
+
if (c.ms < 0 || !c.caller_at) continue;
|
|
1919
|
+
const { host, tpl } = path_template(c.url);
|
|
1920
|
+
calls.push({
|
|
1921
|
+
start: c.start,
|
|
1922
|
+
ms: c.ms + (c.body_ms ?? 0),
|
|
1923
|
+
target: `${c.method} ${host}${tpl}`,
|
|
1924
|
+
at: c.caller_at,
|
|
1925
|
+
...c.outer_at ? { outer: c.outer_at } : {},
|
|
1926
|
+
scope: c.path,
|
|
1927
|
+
...c.method === "GET" || c.method === "HEAD" ? { exact: `${c.method} ${c.url}` } : {},
|
|
1928
|
+
...c.bytes !== void 0 ? { bytes: c.bytes } : {},
|
|
1929
|
+
...c.personal ? { personal: true } : {},
|
|
1930
|
+
...c.headers?.["cache-control"] ? { cache: c.headers["cache-control"] } : {}
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
for (const o of s.io ?? []) {
|
|
1934
|
+
const target = WAIT_TARGET[o.type];
|
|
1935
|
+
if (!target || o.open || !o.caller_at) continue;
|
|
1936
|
+
calls.push({
|
|
1937
|
+
start: o.start,
|
|
1938
|
+
ms: o.ms,
|
|
1939
|
+
target,
|
|
1940
|
+
at: o.caller_at
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
const waits = find_wait_patterns({
|
|
1944
|
+
calls,
|
|
1945
|
+
source
|
|
1946
|
+
});
|
|
1947
|
+
const tl = s.analysis.timeline;
|
|
1948
|
+
for (const p of waits) {
|
|
1949
|
+
if (p.kind !== "waits-in-a-row" || w0 === void 0 || !tl) continue;
|
|
1950
|
+
const on_path = wait_save_on_path(p, tl.awaits, tl.window_ms, w0);
|
|
1951
|
+
if (!on_path || on_path.save_ms >= p.save_ms * .8) continue;
|
|
1952
|
+
p.naive_save_ms = p.save_ms;
|
|
1953
|
+
p.save_ms = on_path.save_ms;
|
|
1954
|
+
p.evidence += `. Started together they would save ${p.naive_save_ms} ms of waiting, but only about ${on_path.save_ms} ms comes off the render` + (on_path.bound_by ? `: ${on_path.bound_by} runs about as long beside them and still sets the end` : ": other work runs as long beside them");
|
|
1955
|
+
}
|
|
1956
|
+
patterns.push(...waits);
|
|
1957
|
+
if (stable?.size) {
|
|
1958
|
+
const same = find_same_answers({
|
|
1959
|
+
calls,
|
|
1960
|
+
stable,
|
|
1961
|
+
source,
|
|
1962
|
+
...tl?.awaits && w0 !== void 0 ? {
|
|
1963
|
+
awaits: tl.awaits,
|
|
1964
|
+
w0
|
|
1965
|
+
} : {}
|
|
1966
|
+
});
|
|
1967
|
+
if (same) patterns.push(same);
|
|
1968
|
+
}
|
|
1969
|
+
if (almost?.size) {
|
|
1970
|
+
const near = find_same_answers({
|
|
1971
|
+
calls,
|
|
1972
|
+
stable: new Set(almost.keys()),
|
|
1973
|
+
almost,
|
|
1974
|
+
source,
|
|
1975
|
+
...tl?.awaits && w0 !== void 0 ? {
|
|
1976
|
+
awaits: tl.awaits,
|
|
1977
|
+
w0
|
|
1978
|
+
} : {}
|
|
1979
|
+
});
|
|
1980
|
+
if (near) patterns.push(near);
|
|
1981
|
+
}
|
|
1982
|
+
if (source && Object.keys(s.call_counts).length) {
|
|
1983
|
+
const cold = find_cold_caches({
|
|
1984
|
+
functions: [...fns.values()],
|
|
1985
|
+
call_counts: s.call_counts,
|
|
1986
|
+
source
|
|
1987
|
+
});
|
|
1988
|
+
if (cold) patterns.push(cold);
|
|
1989
|
+
}
|
|
1990
|
+
const og = own_requests(s.meta).find((r) => r.og?.seed)?.og;
|
|
1991
|
+
const whole = s.lineage?.components.filter((c) => c.island && c.whole && c.whole_line) ?? [];
|
|
1992
|
+
if (og?.seed && whole.length) {
|
|
1993
|
+
const read = await this.#source_reader();
|
|
1994
|
+
const readers = [];
|
|
1995
|
+
for (const c of whole) {
|
|
1996
|
+
const lines = read(c.file)?.split("\n");
|
|
1997
|
+
const at = c.whole_line;
|
|
1998
|
+
if (!lines?.[at - 1]) continue;
|
|
1999
|
+
const from = Math.max(1, at - 2);
|
|
2000
|
+
readers.push({
|
|
2001
|
+
name: c.name,
|
|
2002
|
+
file: c.file,
|
|
2003
|
+
line: at,
|
|
2004
|
+
code: lines[at - 1].trim(),
|
|
2005
|
+
context: {
|
|
2006
|
+
start: from,
|
|
2007
|
+
lines: lines.slice(from - 1, at + 2)
|
|
2008
|
+
}
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
const top = og.seed.keys.find((k) => k.shipped);
|
|
2012
|
+
const p = seed_whole_pattern(readers, og.seed_bytes, top ? {
|
|
2013
|
+
key: top.key,
|
|
2014
|
+
bytes: top.bytes
|
|
2015
|
+
} : void 0);
|
|
2016
|
+
if (p) patterns.push(p);
|
|
2017
|
+
}
|
|
2018
|
+
const load_files = [...new Set((s.analysis.timeline?.lanes ?? []).map((l) => l.file))];
|
|
2019
|
+
if (load_files.length) {
|
|
2020
|
+
const read = await this.#source_reader();
|
|
2021
|
+
const work = [];
|
|
2022
|
+
const unread_keys = new Set((s.lineage?.keys ?? []).filter((k) => k.verdict === "unread").map((k) => k.key));
|
|
2023
|
+
const feeds_only_unread = (file, line) => {
|
|
2024
|
+
const fed = (s.lineage?.sources ?? []).filter((x) => x.from === file && x.lines.includes(line));
|
|
2025
|
+
return fed.length > 0 && fed.every((x) => unread_keys.has(x.key));
|
|
2026
|
+
};
|
|
2027
|
+
for (const file of load_files) {
|
|
2028
|
+
const src = read(file);
|
|
2029
|
+
if (!src) continue;
|
|
2030
|
+
const lines = src.split("\n");
|
|
2031
|
+
for (const w of constant_work(src)) {
|
|
2032
|
+
if (feeds_only_unread(file, w.line)) continue;
|
|
2033
|
+
const own = ledger.filter((l) => l.line === w.line && (l.file.endsWith(file) || file.endsWith(l.file))).reduce((t, l) => t + l.cpu_ms + l.lib_ms, 0);
|
|
2034
|
+
const called = w.calls.reduce((t, c) => {
|
|
2035
|
+
const mod = imported_from(src, c, file);
|
|
2036
|
+
return t + [...fns.values()].filter((f) => f.name === c && f.category === "app" && !!mod && f.path.includes(`/${mod}.`)).reduce((u, f) => u + f.total_ms, 0);
|
|
2037
|
+
}, 0);
|
|
2038
|
+
const from = Math.max(1, w.line - 2);
|
|
2039
|
+
work.push({
|
|
2040
|
+
path: file,
|
|
2041
|
+
file,
|
|
2042
|
+
line: w.line,
|
|
2043
|
+
code: lines[w.line - 1].trim(),
|
|
2044
|
+
names: w.names,
|
|
2045
|
+
calls: w.calls,
|
|
2046
|
+
cpu_ms: own + called,
|
|
2047
|
+
context: {
|
|
2048
|
+
start: from,
|
|
2049
|
+
lines: lines.slice(from - 1, w.line + 2)
|
|
2050
|
+
}
|
|
2051
|
+
});
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
const p = same_work_pattern(work, renders);
|
|
2055
|
+
if (p) {
|
|
2056
|
+
const file = p.sites[0].path;
|
|
2057
|
+
const src = read(file);
|
|
2058
|
+
const plan = src ? hoist_plan(src) : null;
|
|
2059
|
+
if (src && plan) {
|
|
2060
|
+
const lines = src.split("\n");
|
|
2061
|
+
const stmt = (x) => lines.slice(x.line - 1, x.end);
|
|
2062
|
+
const dedent = (ls) => ls.map((l) => l.startsWith(" ") ? l.slice(1) : l.startsWith(" ") ? l.slice(2) : l);
|
|
2063
|
+
p.sites[0].rewrite = {
|
|
2064
|
+
file,
|
|
2065
|
+
line: plan.stmts[0].line,
|
|
2066
|
+
before: plan.stmts.map((x) => stmt(x).join("\n")).join("\n // …\n"),
|
|
2067
|
+
after: `// at the top level, above line ${plan.above} (\`export … load\`), unchanged, and gone from load():\n${plan.stmts.map((x) => dedent(stmt(x)).join("\n")).join("\n")}`
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
patterns.push(p);
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
if (s.meta.doc_diff?.groups.some((g) => g.attr && !g.sources && !g.library)) {
|
|
2074
|
+
const read = await this.#source_reader();
|
|
2075
|
+
const norm = (f) => {
|
|
2076
|
+
const at = f.lastIndexOf("/src/");
|
|
2077
|
+
return at !== -1 ? f.slice(at + 5) : f.startsWith("src/") ? f.slice(4) : f;
|
|
2078
|
+
};
|
|
2079
|
+
const files = [.../* @__PURE__ */ new Set([...(s.lineage?.components ?? []).map((c) => norm(c.file)), ...s.analysis.components.filter((c) => c.url.endsWith(".svelte")).map((c) => norm(c.url))])];
|
|
2080
|
+
for (const g of s.meta.doc_diff.groups) {
|
|
2081
|
+
if (!g.attr || g.attr.includes(",")) continue;
|
|
2082
|
+
const tags = g.tag.split(", ");
|
|
2083
|
+
const found = [];
|
|
2084
|
+
let mentioned = false;
|
|
2085
|
+
for (const f of files) {
|
|
2086
|
+
const src = read(f);
|
|
2087
|
+
if (!src) continue;
|
|
2088
|
+
if (src.includes(g.attr)) mentioned = true;
|
|
2089
|
+
for (const x of attr_sites(src, g.attr, tags)) if (found.length < 3) found.push({
|
|
2090
|
+
file: f,
|
|
2091
|
+
...x
|
|
2092
|
+
});
|
|
2093
|
+
}
|
|
2094
|
+
if (found.length) g.sources = found;
|
|
2095
|
+
else if (!mentioned) g.library = true;
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
if (s.meta.trigger === "page" && s.meta.prerendered === void 0) {
|
|
2099
|
+
const read = await this.#source_reader();
|
|
2100
|
+
const reqs = s.meta.requests ?? [];
|
|
2101
|
+
const route = s.meta.run_route ?? (reqs.find((r) => r.route && r.path.split("?")[0] === s.meta.page?.split("?")[0]) ?? reqs.find((r) => r.route))?.route;
|
|
2102
|
+
const leaf = [...new Set((s.analysis.timeline?.lanes ?? []).map((l) => l.file))].find((f) => f.includes("+page")) ?? (s.lineage?.components ?? []).find((c) => c.file.endsWith("+page.svelte"))?.file;
|
|
2103
|
+
const dir = route ? `routes${route === "/" ? "" : route}` : leaf ? leaf.slice(0, leaf.lastIndexOf("/")) : "";
|
|
2104
|
+
if (s.meta.run_static || dir && route_prerendered(read, dir)) s.meta.prerendered = true;
|
|
2105
|
+
}
|
|
2106
|
+
const near = s.meta.doc_diff && s.meta.doc_diff.differing <= s.meta.doc_diff.tokens * .05 && s.meta.doc_diff.groups.length <= 6 ? s.meta.doc_diff : void 0;
|
|
2107
|
+
if (!s.meta.prerendered && (s.meta.same_document || near) && s.meta.runs?.length && Math.min(...s.meta.runs) >= 5 && (s.meta.run_status ?? 200) === 200) {
|
|
2108
|
+
const read = await this.#source_reader();
|
|
2109
|
+
const files = [...new Set((s.analysis.timeline?.lanes ?? []).map((l) => l.file))];
|
|
2110
|
+
const VISITOR = /* @__PURE__ */ new Set([
|
|
2111
|
+
"cookies",
|
|
2112
|
+
"locals",
|
|
2113
|
+
"request",
|
|
2114
|
+
"getClientAddress",
|
|
2115
|
+
"platform"
|
|
2116
|
+
]);
|
|
2117
|
+
let visitor = s.net.some((c) => c.personal);
|
|
2118
|
+
const route_files = new Set(files);
|
|
2119
|
+
const leaf = files.find((f) => f.includes("+page")) ?? (s.lineage?.components ?? []).find((c) => c.file.endsWith("+page.svelte"))?.file;
|
|
2120
|
+
if (leaf) {
|
|
2121
|
+
let dir = leaf.slice(0, leaf.lastIndexOf("/"));
|
|
2122
|
+
let first = true;
|
|
2123
|
+
while (dir) {
|
|
2124
|
+
const names = [
|
|
2125
|
+
...first ? [
|
|
2126
|
+
"+page.server.ts",
|
|
2127
|
+
"+page.server.js",
|
|
2128
|
+
"+page.ts",
|
|
2129
|
+
"+page.js"
|
|
2130
|
+
] : [],
|
|
2131
|
+
"+layout.server.ts",
|
|
2132
|
+
"+layout.server.js",
|
|
2133
|
+
"+layout.ts",
|
|
2134
|
+
"+layout.js"
|
|
2135
|
+
];
|
|
2136
|
+
for (const n of names) if (read(`${dir}/${n}`) !== void 0) route_files.add(`${dir}/${n}`);
|
|
2137
|
+
first = false;
|
|
2138
|
+
const up = dir.lastIndexOf("/");
|
|
2139
|
+
if (up === -1) break;
|
|
2140
|
+
dir = dir.slice(0, up);
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
for (const f of route_files) {
|
|
2144
|
+
const src = read(f);
|
|
2145
|
+
if ((src ? load_inputs(src) : null)?.some((n) => VISITOR.has(n) || n.startsWith("*"))) visitor = true;
|
|
2146
|
+
}
|
|
2147
|
+
if (!visitor) {
|
|
2148
|
+
const page_file = files.find((f) => f.includes("+page")) ?? files[0] ?? s.meta.page ?? "";
|
|
2149
|
+
const code = page_file ? read(page_file)?.split("\n").find((l) => l.includes("export") && l.includes("load"))?.trim() : void 0;
|
|
2150
|
+
const runs = [...s.meta.runs].sort((a, b) => a - b);
|
|
2151
|
+
const render_ms = runs[Math.floor(runs.length / 2)];
|
|
2152
|
+
const cc = s.meta.run_cache_control;
|
|
2153
|
+
const shut = cc?.toLowerCase().split(",").some((d) => d.trim() === "private" || d.trim() === "no-store");
|
|
2154
|
+
if (s.meta.same_document) patterns.push(same_document_pattern({
|
|
2155
|
+
file: page_file,
|
|
2156
|
+
render_ms,
|
|
2157
|
+
bytes: s.meta.run_bytes ?? 0,
|
|
2158
|
+
...code ? { code } : {},
|
|
2159
|
+
...s.meta.run_sets_cookie || shut ? { blocked: {
|
|
2160
|
+
...s.meta.run_sets_cookie ? { cookie: true } : {},
|
|
2161
|
+
...shut ? { cache_control: cc } : {}
|
|
2162
|
+
} } : {}
|
|
2163
|
+
}));
|
|
2164
|
+
else if (near) patterns.push(almost_same_document_pattern({
|
|
2165
|
+
file: page_file,
|
|
2166
|
+
render_ms,
|
|
2167
|
+
diff: near,
|
|
2168
|
+
...code ? { code } : {}
|
|
2169
|
+
}));
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
{
|
|
2173
|
+
const read = await this.#source_reader();
|
|
2174
|
+
const comps = s.analysis.components;
|
|
2175
|
+
const norm = (f) => f.startsWith("src/") ? f.slice(4) : f;
|
|
2176
|
+
const blocks_of = /* @__PURE__ */ new Map();
|
|
2177
|
+
const items = [];
|
|
2178
|
+
for (const c of comps) {
|
|
2179
|
+
if (!c.calls || c.calls < 20 || !c.parent) continue;
|
|
2180
|
+
let file = "";
|
|
2181
|
+
let src;
|
|
2182
|
+
let b;
|
|
2183
|
+
let up = c.parent;
|
|
2184
|
+
for (let hop = 0; up && hop < 4 && !b; hop++) {
|
|
2185
|
+
const parent = comps.find((p) => p.name === up);
|
|
2186
|
+
up = parent?.parent;
|
|
2187
|
+
if (!parent) continue;
|
|
2188
|
+
let own = parent.url.endsWith(".svelte") ? parent.url : void 0;
|
|
2189
|
+
if (!own && parent.path) {
|
|
2190
|
+
const id = this.#module_find(parent.path, parent.line);
|
|
2191
|
+
if (id?.endsWith(".svelte")) own = id;
|
|
2192
|
+
}
|
|
2193
|
+
if (!own) continue;
|
|
2194
|
+
file = norm(own);
|
|
2195
|
+
src = read(file);
|
|
2196
|
+
if (!src) continue;
|
|
2197
|
+
if (!blocks_of.has(file)) blocks_of.set(file, each_blocks(src));
|
|
2198
|
+
b = blocks_of.get(file).find((x) => x.components.includes(c.name));
|
|
2199
|
+
}
|
|
2200
|
+
if (!b || !src) continue;
|
|
2201
|
+
const lines = src.split("\n");
|
|
2202
|
+
const from = Math.max(1, b.line - 2);
|
|
2203
|
+
let made_at;
|
|
2204
|
+
for (const x of b.key ? (s.lineage?.sources ?? []).filter((y) => y.key === b.key) : []) {
|
|
2205
|
+
const sorted = [...x.lines].sort((m, n) => n - m);
|
|
2206
|
+
const at = sorted[1] ?? sorted[0];
|
|
2207
|
+
const code = at ? read(x.from)?.split("\n")[at - 1]?.trim() : void 0;
|
|
2208
|
+
if (at && code) made_at = {
|
|
2209
|
+
path: x.from,
|
|
2210
|
+
file: x.from,
|
|
2211
|
+
line: at,
|
|
2212
|
+
code
|
|
2213
|
+
};
|
|
2214
|
+
}
|
|
2215
|
+
items.push({
|
|
2216
|
+
component: c.name,
|
|
2217
|
+
instances: c.calls,
|
|
2218
|
+
total_ms: c.total_ms,
|
|
2219
|
+
each: {
|
|
2220
|
+
path: file,
|
|
2221
|
+
file,
|
|
2222
|
+
line: b.line,
|
|
2223
|
+
code: lines[b.line - 1].trim(),
|
|
2224
|
+
context: {
|
|
2225
|
+
start: from,
|
|
2226
|
+
lines: lines.slice(from - 1, b.line + 2)
|
|
2227
|
+
}
|
|
2228
|
+
},
|
|
2229
|
+
...b.key ? { key: b.key } : {},
|
|
2230
|
+
...made_at ? { made_at } : {}
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
const p = render_per_item_pattern(items, renders);
|
|
2234
|
+
if (p) patterns.push(p);
|
|
2235
|
+
}
|
|
2236
|
+
if (source) {
|
|
2237
|
+
const files = /* @__PURE__ */ new Map();
|
|
2238
|
+
const opts_for = (path) => {
|
|
2239
|
+
let o = files.get(path);
|
|
2240
|
+
if (o) return o;
|
|
2241
|
+
const all = source(path, 1, Number.MAX_SAFE_INTEGER)?.lines;
|
|
2242
|
+
if (!all) return void 0;
|
|
2243
|
+
const svelte = path.endsWith(".svelte");
|
|
2244
|
+
const tag = svelte ? all.find((l) => l.includes("<script") && l.includes("lang=")) : void 0;
|
|
2245
|
+
const ts = path.endsWith(".ts") || path.endsWith(".mts") || path.endsWith(".cts") || !!tag && (tag.includes("ts\"") || tag.includes("ts'") || tag.includes("=ts") || tag.includes("typescript"));
|
|
2246
|
+
const taken = /* @__PURE__ */ new Set();
|
|
2247
|
+
for (const l of all) for (const t of tokens(l)) taken.add(t);
|
|
2248
|
+
o = {
|
|
2249
|
+
ts,
|
|
2250
|
+
svelte,
|
|
2251
|
+
taken,
|
|
2252
|
+
made: /* @__PURE__ */ new Map()
|
|
2253
|
+
};
|
|
2254
|
+
files.set(path, o);
|
|
2255
|
+
return o;
|
|
2256
|
+
};
|
|
2257
|
+
for (const p of patterns) {
|
|
2258
|
+
if (p.kind !== "formatter-per-call") continue;
|
|
2259
|
+
for (const site of p.sites) {
|
|
2260
|
+
if (site.rewrite) continue;
|
|
2261
|
+
const full = source(site.path, site.line, site.line)?.lines[0];
|
|
2262
|
+
const opts = opts_for(site.path);
|
|
2263
|
+
if (!full || !opts) continue;
|
|
2264
|
+
const rw = formatter_rewrite(full, opts);
|
|
2265
|
+
if (rw) {
|
|
2266
|
+
site.rewrite = {
|
|
2267
|
+
file: site.file,
|
|
2268
|
+
line: site.line,
|
|
2269
|
+
...rw
|
|
2270
|
+
};
|
|
2271
|
+
continue;
|
|
2272
|
+
}
|
|
2273
|
+
const above = source(site.path, Math.max(1, site.line - 3), site.line - 1);
|
|
2274
|
+
if (!above) continue;
|
|
2275
|
+
const used = tokens(full);
|
|
2276
|
+
for (let k = above.lines.length - 1; k >= 0; k--) {
|
|
2277
|
+
const l = above.lines[k];
|
|
2278
|
+
if (!l.includes("new Intl.")) continue;
|
|
2279
|
+
const name = line_rw(l).declared[0];
|
|
2280
|
+
if (!name || !used.some((t, i) => t === name && used[i + 1] === "." && used[i - 1] !== ".")) continue;
|
|
2281
|
+
const r2 = formatter_rewrite(l, opts);
|
|
2282
|
+
if (r2) site.rewrite = {
|
|
2283
|
+
file: site.file,
|
|
2284
|
+
line: above.start + k,
|
|
2285
|
+
...r2
|
|
2286
|
+
};
|
|
2287
|
+
break;
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
const route = own_requests(s.meta).find((r) => r.route)?.route ?? s.meta.request?.route;
|
|
2293
|
+
if (route) for (let i = patterns.length - 1; i >= 0; i--) {
|
|
2294
|
+
const sites = patterns[i].sites;
|
|
2295
|
+
if (sites.length && sites.every((x) => another_routes_file(route, x.path || x.file))) patterns.splice(i, 1);
|
|
2296
|
+
}
|
|
2297
|
+
if (route && s.call_counts && !patterns.some((p) => p.kind === "sync-io")) try {
|
|
2298
|
+
const read = await this.#source_reader();
|
|
2299
|
+
const { join } = await import("node:path");
|
|
2300
|
+
const dirs = route_dirs(route);
|
|
2301
|
+
const rels = [];
|
|
2302
|
+
const add = (dir, names) => {
|
|
2303
|
+
for (const n of names) for (const ext of [".ts", ".js"]) rels.push(`routes/${dir ? dir + "/" : ""}${n}${ext}`);
|
|
2304
|
+
};
|
|
2305
|
+
for (const d of dirs.above("src")) add(d, ["+layout.server", "+layout"]);
|
|
2306
|
+
add(dirs.own("src"), ["+page.server", "+page"]);
|
|
2307
|
+
for (const ext of [".ts", ".js"]) rels.push(`hooks.server${ext}`);
|
|
2308
|
+
const files = [];
|
|
2309
|
+
for (const rel of rels) {
|
|
2310
|
+
const text = read(rel);
|
|
2311
|
+
if (text) files.push({
|
|
2312
|
+
path: join(process.cwd(), "src", rel),
|
|
2313
|
+
file: rel,
|
|
2314
|
+
text
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
const own = new Set(files.map((f) => f.file));
|
|
2318
|
+
for (const f of files.slice()) for (const rel of module_imports(f.file, f.text)) {
|
|
2319
|
+
if (own.size >= 30 + rels.length) break;
|
|
2320
|
+
if (own.has(rel)) continue;
|
|
2321
|
+
const text = read(rel);
|
|
2322
|
+
if (!text) continue;
|
|
2323
|
+
own.add(rel);
|
|
2324
|
+
files.push({
|
|
2325
|
+
path: join(process.cwd(), "src", rel),
|
|
2326
|
+
file: rel,
|
|
2327
|
+
text
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
2330
|
+
const p = counted_sync_io(s.call_counts, files);
|
|
2331
|
+
if (p) patterns.push(p);
|
|
2332
|
+
} catch {}
|
|
2333
|
+
const severe = (p) => (p.kept_bytes ?? 0) >= 8388608 && p.growth !== "levels-off";
|
|
2334
|
+
const per_render = (p) => p.wait ? p.save_ms : p.save_ms / renders;
|
|
2335
|
+
patterns.sort((a, b) => Number(severe(b)) - Number(severe(a)) || per_render(b) - per_render(a));
|
|
2336
|
+
if (patterns.length) s.patterns = patterns;
|
|
2337
|
+
const label = (x) => this.#label_module(x);
|
|
2338
|
+
for (const p of s.patterns ?? []) for (const site of p.sites) {
|
|
2339
|
+
label(site);
|
|
2340
|
+
for (const v of site.via ?? []) label(v);
|
|
2341
|
+
}
|
|
2342
|
+
const tl_drill = s.analysis.timeline;
|
|
2343
|
+
if (tl_drill) {
|
|
2344
|
+
const renders_of = /* @__PURE__ */ new Map();
|
|
2345
|
+
for (const c of s.analysis.components) if (c.calls) renders_of.set(c.name, c.calls);
|
|
2346
|
+
const cold_owners = new Map((s.meta.cold?.owners ?? []).map((o) => [o.label, o.ms]));
|
|
2347
|
+
const drill = build_drill(tl_drill, ledger, patterns, s.gc_attr?.makers ?? [], renders_of, s.analysis.owner_runs_ms ?? {}, cold_owners, wait_runs ?? {});
|
|
2348
|
+
if (drill && source) {
|
|
2349
|
+
const with_code = (n) => {
|
|
2350
|
+
for (const c of n.children ?? []) {
|
|
2351
|
+
const fl = c.kind === "line" && c.at ? at_line(c.at) : void 0;
|
|
2352
|
+
const code = fl ? source(fl.file, fl.line, fl.line)?.lines[0]?.trim() : void 0;
|
|
2353
|
+
if (code) c.code = code.slice(0, 120);
|
|
2354
|
+
with_code(c);
|
|
2355
|
+
}
|
|
2356
|
+
};
|
|
2357
|
+
with_code(drill);
|
|
2358
|
+
}
|
|
2359
|
+
if (drill && s.lineage?.sources) {
|
|
2360
|
+
const verdict = new Map(s.lineage.keys.map((k) => [k.key, k.verdict]));
|
|
2361
|
+
tag_fills(drill, s.lineage.sources.map((x) => ({
|
|
2362
|
+
...x,
|
|
2363
|
+
verdict: verdict.get(x.key) ?? "unknown"
|
|
2364
|
+
})));
|
|
2365
|
+
const read = await this.#source_reader();
|
|
2366
|
+
const late = /* @__PURE__ */ new Map();
|
|
2367
|
+
for (const c of s.lineage.components) {
|
|
2368
|
+
if (c.island || !c.file.endsWith(".svelte")) continue;
|
|
2369
|
+
const src = read(c.file);
|
|
2370
|
+
if (!src) continue;
|
|
2371
|
+
const lines = src.split("\n");
|
|
2372
|
+
for (const k of late_island_keys(src)) if (!s.lineage.components.some((o) => o.file !== c.file && (o.whole || (o.reads ?? []).includes(k.key)))) late.set(k.key, {
|
|
2373
|
+
island: k.island,
|
|
2374
|
+
wake: k.wake,
|
|
2375
|
+
file: c.file,
|
|
2376
|
+
line: k.line,
|
|
2377
|
+
code: lines[k.line - 1]?.trim() ?? ""
|
|
2378
|
+
});
|
|
2379
|
+
}
|
|
2380
|
+
if (late.size) {
|
|
2381
|
+
const items = [];
|
|
2382
|
+
const rows = [];
|
|
2383
|
+
const walk = (n) => {
|
|
2384
|
+
if (n.kind === "wait" && n.fills?.length && n.fills.every((f) => late.has(f.key))) rows.push(n);
|
|
2385
|
+
for (const c of n.children ?? []) walk(c);
|
|
2386
|
+
};
|
|
2387
|
+
walk(drill);
|
|
2388
|
+
for (const r of rows) {
|
|
2389
|
+
const line = r.children?.find((c) => c.kind === "line");
|
|
2390
|
+
const at = line?.children?.find((c) => c.kind === "line")?.at ?? line?.at;
|
|
2391
|
+
if (!at) continue;
|
|
2392
|
+
const colon = at.lastIndexOf(":");
|
|
2393
|
+
const file = at.slice(0, colon);
|
|
2394
|
+
const n = Number(at.slice(colon + 1));
|
|
2395
|
+
const k = late.get(r.fills[0].key);
|
|
2396
|
+
items.push({
|
|
2397
|
+
call: r.label,
|
|
2398
|
+
ms: r.ms,
|
|
2399
|
+
alone: r.alone ?? 0,
|
|
2400
|
+
at: {
|
|
2401
|
+
path: file,
|
|
2402
|
+
file,
|
|
2403
|
+
line: n,
|
|
2404
|
+
code: read(file)?.split("\n")[n - 1]?.trim() ?? ""
|
|
2405
|
+
},
|
|
2406
|
+
key: r.fills[0].key,
|
|
2407
|
+
island: k.island,
|
|
2408
|
+
wake: k.wake,
|
|
2409
|
+
use: {
|
|
2410
|
+
path: k.file,
|
|
2411
|
+
file: k.file,
|
|
2412
|
+
line: k.line,
|
|
2413
|
+
code: k.code
|
|
2414
|
+
}
|
|
2415
|
+
});
|
|
2416
|
+
r.why = [...r.why ?? [], "late-island-wait"];
|
|
2417
|
+
}
|
|
2418
|
+
const p = late_island_pattern(items);
|
|
2419
|
+
if (p) {
|
|
2420
|
+
const list = s.patterns ??= [];
|
|
2421
|
+
list.push(p);
|
|
2422
|
+
list.sort((a, b) => Number(severe(b)) - Number(severe(a)) || per_render(b) - per_render(a));
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
if (drill) s.drill = drill;
|
|
2427
|
+
}
|
|
2428
|
+
if (s.meta.trigger === "page" && (s.meta.run_status ?? 200) === 200) {
|
|
2429
|
+
const by_key = new Map(s.analysis.functions.map((fn) => [fn.key, fn]));
|
|
2430
|
+
const f = forecast_of(s.meta.runs, s.patterns, s.drill, {
|
|
2431
|
+
wait_cap: s.analysis.timeline?.wait_ms,
|
|
2432
|
+
cpu_cap: s.analysis.timeline?.cpu_ms,
|
|
2433
|
+
stacks: (k) => by_key.get(k)?.stacks,
|
|
2434
|
+
...s.meta.answers?.runs_ms.length ? { answers_runs: s.meta.answers.runs_ms } : {},
|
|
2435
|
+
cpu_main: s.analysis.functions.filter((fn) => MAIN_THREAD_CPU.has(fn.category)).reduce((t, fn) => t + fn.self_ms, 0) / Math.max(1, s.meta.runs?.length ?? 1)
|
|
2436
|
+
});
|
|
2437
|
+
if (f) s.forecast = s.analysis.sourcemapped || s.analysis.text_mapped ? f : {
|
|
2438
|
+
...f,
|
|
2439
|
+
partial: true
|
|
2440
|
+
};
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
/** A reader of app sources at report time (a file relative to `src/` or the cwd, or absolute);
|
|
2444
|
+
* undefined where there is no file system, or when the file is not on this machine. */
|
|
2445
|
+
/** THE DEV SERVER: which page.data keys an island's code reads, from the plugin's run of the build's
|
|
2446
|
+
* analysis (vite/dev-maps.ts), by the island id in its entry (`…/virtual:ogygia/island/<id>.js`).
|
|
2447
|
+
* Undefined off the dev server or when the plugin cannot say */
|
|
2448
|
+
async #dev_island_keys(entry) {
|
|
2449
|
+
if (!this.dev) return void 0;
|
|
2450
|
+
const ask = globalThis[Symbol.for("ogygia.profiler.dev-page-keys")];
|
|
2451
|
+
const at = entry.indexOf("virtual:ogygia/island/");
|
|
2452
|
+
if (!ask || at === -1) return void 0;
|
|
2453
|
+
const rest = entry.slice(at + 22);
|
|
2454
|
+
const dot = rest.indexOf(".");
|
|
2455
|
+
try {
|
|
2456
|
+
return await ask(dot === -1 ? rest : rest.slice(0, dot));
|
|
2457
|
+
} catch {
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
async #source_reader() {
|
|
2462
|
+
let fs;
|
|
2463
|
+
let path;
|
|
2464
|
+
try {
|
|
2465
|
+
fs = await import("node:fs");
|
|
2466
|
+
path = await import("node:path");
|
|
2467
|
+
} catch {
|
|
2468
|
+
fs = void 0;
|
|
2469
|
+
}
|
|
2470
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2471
|
+
return (file) => {
|
|
2472
|
+
if (!fs || !path) return void 0;
|
|
2473
|
+
if (cache.has(file)) return cache.get(file);
|
|
2474
|
+
const candidates = file.startsWith("/") ? [file] : [path.join(process.cwd(), "src", file), path.join(process.cwd(), file)];
|
|
2475
|
+
let out;
|
|
2476
|
+
for (const c of candidates) try {
|
|
2477
|
+
out = fs.readFileSync(c, "utf8");
|
|
2478
|
+
break;
|
|
2479
|
+
} catch {}
|
|
2480
|
+
out ??= embedded_source(file);
|
|
2481
|
+
cache.set(file, out);
|
|
2482
|
+
return out;
|
|
2483
|
+
};
|
|
2484
|
+
}
|
|
2485
|
+
/** DATA LINEAGE FROM THE CODE (lineage.ts): the route's page and layouts, every component the
|
|
2486
|
+
* profile saw with a `.svelte` source on this machine, and the islands (their keys from the
|
|
2487
|
+
* build), each with the page.data keys it reads — joined to the loads' keys and the seed. */
|
|
2488
|
+
/** every `.svelte` under the app's `src/`, relative to it, listed once (null: no src here) */
|
|
2489
|
+
#svelte_files;
|
|
2490
|
+
/** The `src/`-relative paths of `<name>.svelte` files (a component is named after its file). */
|
|
2491
|
+
async #component_files(name) {
|
|
2492
|
+
this.#svelte_files ??= (async () => {
|
|
2493
|
+
try {
|
|
2494
|
+
const fs = await import("node:fs");
|
|
2495
|
+
const path = await import("node:path");
|
|
2496
|
+
const root = path.join(process.cwd(), "src");
|
|
2497
|
+
const out = [];
|
|
2498
|
+
for (const e of fs.readdirSync(root, {
|
|
2499
|
+
recursive: true,
|
|
2500
|
+
withFileTypes: true
|
|
2501
|
+
})) {
|
|
2502
|
+
if (!e.isFile() || !e.name.endsWith(".svelte")) continue;
|
|
2503
|
+
const rel = path.relative(root, path.join(e.parentPath, e.name)).split(path.sep).join("/");
|
|
2504
|
+
if (!rel.includes("node_modules/")) out.push(rel);
|
|
2505
|
+
}
|
|
2506
|
+
return out;
|
|
2507
|
+
} catch {
|
|
2508
|
+
const carried = embedded_files().filter((f) => f.endsWith(".svelte"));
|
|
2509
|
+
return carried.length ? carried : null;
|
|
2510
|
+
}
|
|
2511
|
+
})();
|
|
2512
|
+
const all = await this.#svelte_files;
|
|
2513
|
+
const leaf = "/" + name + ".svelte";
|
|
2514
|
+
return all ? all.filter((f) => f === name + ".svelte" || f.endsWith(leaf)) : [];
|
|
2515
|
+
}
|
|
2516
|
+
async #lineage_for(stored, body) {
|
|
2517
|
+
const a = stored.analysis;
|
|
2518
|
+
const tl = a.timeline;
|
|
2519
|
+
const read = await this.#source_reader();
|
|
2520
|
+
const lanes = tl?.lanes ?? [];
|
|
2521
|
+
const lane_files = [...new Set(lanes.map((l) => l.file))];
|
|
2522
|
+
if (tl?.chain) {
|
|
2523
|
+
const src = read(tl.chain.page);
|
|
2524
|
+
const use = src ? parent_use(src) : null;
|
|
2525
|
+
if (use) tl.chain.parent_use = use;
|
|
2526
|
+
}
|
|
2527
|
+
const runs = Math.max(stored.meta.runs?.length ?? 1, 1);
|
|
2528
|
+
const wait_by_lane = /* @__PURE__ */ new Map();
|
|
2529
|
+
for (const c of stored.net) {
|
|
2530
|
+
const text = [
|
|
2531
|
+
c.caller_site?.file ?? "",
|
|
2532
|
+
...c.callers ?? [],
|
|
2533
|
+
c.caller ?? ""
|
|
2534
|
+
].join(" ");
|
|
2535
|
+
const lane = load_lane_of(text)?.file;
|
|
2536
|
+
if (!lane) continue;
|
|
2537
|
+
const file = lane_files.includes(lane) ? lane : lane_files.find((f) => f.endsWith(lane) || lane.endsWith(f));
|
|
2538
|
+
if (!file) continue;
|
|
2539
|
+
wait_by_lane.set(file, (wait_by_lane.get(file) ?? 0) + (Math.max(c.ms, 0) + (c.body_ms ?? 0)) / runs);
|
|
2540
|
+
}
|
|
2541
|
+
const lineage_lanes = lane_files.map((file) => {
|
|
2542
|
+
const src = read(file);
|
|
2543
|
+
return {
|
|
2544
|
+
file,
|
|
2545
|
+
keys: src ? load_return_keys(src) : null,
|
|
2546
|
+
...wait_by_lane.has(file) ? { wait_ms: round2(wait_by_lane.get(file)) } : {}
|
|
2547
|
+
};
|
|
2548
|
+
});
|
|
2549
|
+
const files = /* @__PURE__ */ new Map();
|
|
2550
|
+
const route_dirs = /* @__PURE__ */ new Set();
|
|
2551
|
+
for (const f of lane_files) {
|
|
2552
|
+
const at = f.lastIndexOf("/");
|
|
2553
|
+
if (at === -1) continue;
|
|
2554
|
+
let dir = f.slice(0, at);
|
|
2555
|
+
while (dir) {
|
|
2556
|
+
route_dirs.add(dir);
|
|
2557
|
+
const up = dir.lastIndexOf("/");
|
|
2558
|
+
if (up === -1 || dir === "routes") break;
|
|
2559
|
+
dir = dir.slice(0, up);
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
for (const dir of route_dirs) for (const leaf of ["+page.svelte", "+layout.svelte"]) {
|
|
2563
|
+
const file = `${dir}/${leaf}`;
|
|
2564
|
+
if (read(file) !== void 0 && !files.has(file)) files.set(file, {
|
|
2565
|
+
name: `${dir.slice(dir.lastIndexOf("/") + 1)}/${leaf}`,
|
|
2566
|
+
island: false
|
|
2567
|
+
});
|
|
2568
|
+
}
|
|
2569
|
+
const norm = (f) => f.startsWith("src/") ? f.slice(4) : f;
|
|
2570
|
+
for (const c of a.components) {
|
|
2571
|
+
if (!c.url.endsWith(".svelte")) continue;
|
|
2572
|
+
const file = norm(c.url);
|
|
2573
|
+
if (files.has(file)) continue;
|
|
2574
|
+
if (read(file) !== void 0) files.set(file, {
|
|
2575
|
+
name: c.name,
|
|
2576
|
+
island: false
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2579
|
+
const seed_stats = own_requests(stored.meta).find((r) => r.og?.seed)?.og?.seed;
|
|
2580
|
+
const seen_entries = /* @__PURE__ */ new Set();
|
|
2581
|
+
const islands = [];
|
|
2582
|
+
for (const r of island_rows_of(stored.meta)) {
|
|
2583
|
+
if (seen_entries.has(r.entry)) continue;
|
|
2584
|
+
seen_entries.add(r.entry);
|
|
2585
|
+
const name = island_name(r);
|
|
2586
|
+
let keys;
|
|
2587
|
+
const dev = await this.#dev_island_keys(r.entry);
|
|
2588
|
+
if (dev) keys = dev.keys === "all" ? ["*"] : dev.keys ?? [];
|
|
2589
|
+
else if (seed_stats) {
|
|
2590
|
+
keys = seed_stats.keys.filter((k) => k.readers.includes(name)).map((k) => k.key);
|
|
2591
|
+
if (seed_stats.whole_by.includes(name)) keys.push("*");
|
|
2592
|
+
} else {
|
|
2593
|
+
try {
|
|
2594
|
+
keys = islandPageKeys(r.entry);
|
|
2595
|
+
} catch {
|
|
2596
|
+
keys = void 0;
|
|
2597
|
+
}
|
|
2598
|
+
if (keys === void 0) {
|
|
2599
|
+
const src = read(r.entry);
|
|
2600
|
+
keys = src ? data_reads(src, data_prop_names(src)) : null;
|
|
2601
|
+
} else if (keys === null) keys = ["*"];
|
|
2602
|
+
}
|
|
2603
|
+
let file = norm(r.entry);
|
|
2604
|
+
let whole_line;
|
|
2605
|
+
if (keys?.includes("*") || keys === null) {
|
|
2606
|
+
const comp = a.components.find((c) => c.name === name && c.url.endsWith(".svelte"));
|
|
2607
|
+
const named = await this.#component_files(name);
|
|
2608
|
+
const wanted = route_imports(files.keys(), read, name);
|
|
2609
|
+
const candidates = [
|
|
2610
|
+
...comp ? [norm(comp.url)] : [],
|
|
2611
|
+
...named.filter((f) => wanted.has(f)),
|
|
2612
|
+
...named.filter((f) => !wanted.has(f)),
|
|
2613
|
+
file
|
|
2614
|
+
];
|
|
2615
|
+
for (const f of candidates) {
|
|
2616
|
+
const src = read(f);
|
|
2617
|
+
const line = src ? whole_read_line(src, data_prop_names(src)) : null;
|
|
2618
|
+
if (line) {
|
|
2619
|
+
file = f;
|
|
2620
|
+
whole_line = line;
|
|
2621
|
+
break;
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
if (!whole_line) {
|
|
2625
|
+
const whys = [...islandPageWhy(r.entry) ?? [], ...(dev?.why ?? []).map((w) => ({
|
|
2626
|
+
...w,
|
|
2627
|
+
file: app_relative(w.file) ?? w.file
|
|
2628
|
+
}))];
|
|
2629
|
+
for (const w of whys) {
|
|
2630
|
+
if (!w.line) continue;
|
|
2631
|
+
const f = norm(w.file);
|
|
2632
|
+
if (read(f)?.split("\n")[w.line - 1] === void 0) continue;
|
|
2633
|
+
file = f;
|
|
2634
|
+
whole_line = w.line;
|
|
2635
|
+
break;
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
islands.push({
|
|
2640
|
+
name,
|
|
2641
|
+
file,
|
|
2642
|
+
island: true,
|
|
2643
|
+
reads: keys,
|
|
2644
|
+
...whole_line ? { whole_line } : {}
|
|
2645
|
+
});
|
|
2646
|
+
files.delete(norm(r.entry));
|
|
2647
|
+
}
|
|
2648
|
+
const components = [...files.entries()].map(([file, info]) => {
|
|
2649
|
+
const src = read(file);
|
|
2650
|
+
return {
|
|
2651
|
+
name: info.name,
|
|
2652
|
+
file,
|
|
2653
|
+
island: false,
|
|
2654
|
+
reads: data_reads(src, data_prop_names(src))
|
|
2655
|
+
};
|
|
2656
|
+
});
|
|
2657
|
+
if (!components.length && !islands.length) return void 0;
|
|
2658
|
+
const seed_m = SEED_SCRIPT_RE.exec(body);
|
|
2659
|
+
const seed = seed_m ? seed_key_bytes(seed_m[1]) : {};
|
|
2660
|
+
const lineage = build_lineage({
|
|
2661
|
+
components: [...components, ...islands],
|
|
2662
|
+
lanes: lineage_lanes,
|
|
2663
|
+
seed
|
|
2664
|
+
});
|
|
2665
|
+
if (lineage) {
|
|
2666
|
+
const sources = [];
|
|
2667
|
+
for (const file of lane_files) {
|
|
2668
|
+
const src = read(file);
|
|
2669
|
+
const m = src ? key_sources(src) : null;
|
|
2670
|
+
for (const [key, lines] of m ?? []) sources.push({
|
|
2671
|
+
key,
|
|
2672
|
+
from: file,
|
|
2673
|
+
lines,
|
|
2674
|
+
names: names_on(src, lines)
|
|
2675
|
+
});
|
|
2676
|
+
}
|
|
2677
|
+
if (sources.length) lineage.sources = sources;
|
|
2678
|
+
}
|
|
2679
|
+
return lineage;
|
|
2680
|
+
}
|
|
2681
|
+
/** One more render under a LIVE-objects sampler (no collected-objects flag), then a full
|
|
2682
|
+
* collection, then the profile: every sampled object still alive was allocated by that
|
|
2683
|
+
* render and kept by something — the leak, by allocation site. Its own session, after the
|
|
2684
|
+
* runs, so nothing here is charged to them; the sites are source-mapped like the makers. */
|
|
2685
|
+
/** `n` renders with a full collection after each: the used heap after every one — and, from a
|
|
2686
|
+
* live-objects sampler left on across all of them, what each LINE still holds at the end (a
|
|
2687
|
+
* leaking line holds about `n` renders' worth; a line filling a bounded cache, about one). */
|
|
2688
|
+
async #growth_check(render, n, until = Infinity) {
|
|
2689
|
+
const { Session } = await import("node:inspector/promises");
|
|
2690
|
+
const session = new Session();
|
|
2691
|
+
session.connect();
|
|
2692
|
+
try {
|
|
2693
|
+
const settled = async () => {
|
|
2694
|
+
await session.post("HeapProfiler.collectGarbage");
|
|
2695
|
+
return process.memoryUsage().heapUsed;
|
|
2696
|
+
};
|
|
2697
|
+
const series = [await settled()];
|
|
2698
|
+
await session.post("HeapProfiler.enable");
|
|
2699
|
+
await session.post("HeapProfiler.startSampling", { samplingInterval: LIVE_SAMPLING_BYTES });
|
|
2700
|
+
const half = Math.floor(n / 2);
|
|
2701
|
+
let half_head;
|
|
2702
|
+
let last = 0;
|
|
2703
|
+
for (let i = 0; i < n; i++) {
|
|
2704
|
+
if (i >= 3 && Date.now() + last > until) break;
|
|
2705
|
+
const t = Date.now();
|
|
2706
|
+
await render();
|
|
2707
|
+
series.push(await settled());
|
|
2708
|
+
if (i + 1 === 3 && is_flat(series)) break;
|
|
2709
|
+
if (i + 1 === 3 && is_clear_leak(series)) {
|
|
2710
|
+
half_head = void 0;
|
|
2711
|
+
break;
|
|
2712
|
+
}
|
|
2713
|
+
if (i + 1 === half) half_head = (await session.post("HeapProfiler.getSamplingProfile")).profile?.head;
|
|
2714
|
+
last = Date.now() - t;
|
|
2715
|
+
}
|
|
2716
|
+
const res = await session.post("HeapProfiler.getSamplingProfile");
|
|
2717
|
+
await session.post("HeapProfiler.stopSampling");
|
|
2718
|
+
const g = heap_growth(series);
|
|
2719
|
+
if (!g) return void 0;
|
|
2720
|
+
const head = res.profile?.head;
|
|
2721
|
+
if (head) g.sites = (await this.#live_sites(head)).rows;
|
|
2722
|
+
if (head && half_head) {
|
|
2723
|
+
g.half_renders = half;
|
|
2724
|
+
g.half_sites = (await this.#live_sites(half_head)).rows;
|
|
2725
|
+
}
|
|
2726
|
+
return g;
|
|
2727
|
+
} finally {
|
|
2728
|
+
try {
|
|
2729
|
+
session.disconnect();
|
|
2730
|
+
} catch {}
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
async #retention_pass(render) {
|
|
2734
|
+
const { Session } = await import("node:inspector/promises");
|
|
2735
|
+
const session = new Session();
|
|
2736
|
+
session.connect();
|
|
2737
|
+
try {
|
|
2738
|
+
await session.post("HeapProfiler.enable");
|
|
2739
|
+
await session.post("HeapProfiler.startSampling", { samplingInterval: LIVE_SAMPLING_BYTES });
|
|
2740
|
+
const t = performance.now();
|
|
2741
|
+
await render();
|
|
2742
|
+
const render_ms = round2(performance.now() - t);
|
|
2743
|
+
await session.post("HeapProfiler.collectGarbage");
|
|
2744
|
+
const res = await session.post("HeapProfiler.getSamplingProfile");
|
|
2745
|
+
await session.post("HeapProfiler.stopSampling");
|
|
2746
|
+
const head = res.profile?.head;
|
|
2747
|
+
if (!head) return void 0;
|
|
2748
|
+
const { total, rows } = await this.#live_sites(head);
|
|
2749
|
+
return {
|
|
2750
|
+
total_bytes: total,
|
|
2751
|
+
render_ms,
|
|
2752
|
+
sites: rows
|
|
2753
|
+
};
|
|
2754
|
+
} finally {
|
|
2755
|
+
try {
|
|
2756
|
+
session.disconnect();
|
|
2757
|
+
} catch {}
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
/** A live-objects heap sample, after a full collection, as rows: one per allocation line (the
|
|
2761
|
+
* same builtin from several stacks is one site), each charged to its app line (`at`). */
|
|
2762
|
+
async #live_sites(head) {
|
|
2763
|
+
{
|
|
2764
|
+
const dict = {};
|
|
2765
|
+
const sites = heap_sites(head, dict);
|
|
2766
|
+
const resolver = await this.#make_resolver();
|
|
2767
|
+
const short = (u) => u.replace(/^file:\/\//, "").split("/").slice(-2).join("/");
|
|
2768
|
+
const by_ident = /* @__PURE__ */ new Map();
|
|
2769
|
+
let total = 0;
|
|
2770
|
+
for (const [key, bytes] of sites) {
|
|
2771
|
+
const d = dict[key];
|
|
2772
|
+
if (!d || d.category === "profiler" || d.category === "v8" || d.category === "gc") continue;
|
|
2773
|
+
let url = d.url;
|
|
2774
|
+
let line = d.line;
|
|
2775
|
+
let caller = d.caller;
|
|
2776
|
+
let at;
|
|
2777
|
+
if (resolver) {
|
|
2778
|
+
const m = url ? resolver.resolve(url, line - 1, 0) : void 0;
|
|
2779
|
+
if (m) {
|
|
2780
|
+
url = m.source;
|
|
2781
|
+
line = m.line;
|
|
2782
|
+
}
|
|
2783
|
+
let caller_at;
|
|
2784
|
+
if (d.caller_url && d.caller_name) {
|
|
2785
|
+
const c = resolver.resolve(d.caller_url, (d.caller_line ?? 1) - 1, 0);
|
|
2786
|
+
if (c) {
|
|
2787
|
+
caller = `${d.caller_name} (${short(c.source)}:${c.line})`;
|
|
2788
|
+
caller_at = {
|
|
2789
|
+
path: c.source,
|
|
2790
|
+
line: c.line
|
|
2791
|
+
};
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
const cat = m ? categorize({
|
|
2795
|
+
functionName: d.name,
|
|
2796
|
+
url: m.source,
|
|
2797
|
+
lineNumber: m.line - 1,
|
|
2798
|
+
columnNumber: 0,
|
|
2799
|
+
scriptId: ""
|
|
2800
|
+
}).category : d.category;
|
|
2801
|
+
if ((cat === "app" || cat === "component") && m) at = {
|
|
2802
|
+
path: m.source,
|
|
2803
|
+
line: m.line
|
|
2804
|
+
};
|
|
2805
|
+
else at = caller_at;
|
|
2806
|
+
if (!at && !m) at = this.#chunk_at(d.url, d.line) ?? this.#chunk_at(d.caller_url, d.caller_line);
|
|
2807
|
+
}
|
|
2808
|
+
const ident = `${d.name}|${url}|${line}|${caller ?? ""}`;
|
|
2809
|
+
const row = by_ident.get(ident) ?? {
|
|
2810
|
+
name: d.name,
|
|
2811
|
+
url,
|
|
2812
|
+
line,
|
|
2813
|
+
...caller ? { caller } : {},
|
|
2814
|
+
...at ? { at } : {},
|
|
2815
|
+
component: d.component,
|
|
2816
|
+
bytes: 0,
|
|
2817
|
+
share: 0
|
|
2818
|
+
};
|
|
2819
|
+
row.bytes += bytes;
|
|
2820
|
+
by_ident.set(ident, row);
|
|
2821
|
+
total += bytes;
|
|
2822
|
+
}
|
|
2823
|
+
const rows = [...by_ident.values()].sort((a, b) => b.bytes - a.bytes).slice(0, 30);
|
|
2824
|
+
for (const r of rows) r.share = total ? round2(r.bytes / total) : 0;
|
|
2825
|
+
return {
|
|
2826
|
+
total,
|
|
2827
|
+
rows
|
|
2828
|
+
};
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
#compare(a, b) {
|
|
2832
|
+
const A = this.#reports.get(a ?? "");
|
|
2833
|
+
const B = this.#reports.get(b ?? "");
|
|
2834
|
+
if (!A || !B) return {
|
|
2835
|
+
base: this.base,
|
|
2836
|
+
cmp: null,
|
|
2837
|
+
a: a ?? "",
|
|
2838
|
+
b: b ?? ""
|
|
2839
|
+
};
|
|
2840
|
+
return {
|
|
2841
|
+
base: this.base,
|
|
2842
|
+
cmp: compare_reports(this.#pack(A), this.#pack(B)),
|
|
2843
|
+
a: A.meta.id,
|
|
2844
|
+
b: B.meta.id
|
|
2845
|
+
};
|
|
2846
|
+
}
|
|
2847
|
+
/** a stored report as the comparison reads it */
|
|
2848
|
+
#pack(s) {
|
|
2849
|
+
return {
|
|
2850
|
+
meta: s.meta,
|
|
2851
|
+
analysis: s.analysis,
|
|
2852
|
+
findings: derive_findings(s.analysis, s.meta, this.#report_extras(s)).filter((f) => f.severity === "warn").map((f) => `${f.code}: ${f.message}`),
|
|
2853
|
+
...s.gc_attr ? { gc: s.gc_attr } : {},
|
|
2854
|
+
...s.patterns ? { patterns: s.patterns } : {},
|
|
2855
|
+
...s.ledger ? { ledger: s.ledger } : {},
|
|
2856
|
+
...s.drill ? { drill: s.drill } : {},
|
|
2857
|
+
...s.forecast ? { forecast: s.forecast } : {}
|
|
2858
|
+
};
|
|
2859
|
+
}
|
|
2860
|
+
/** DEV ONLY: nine lines of a local source file around `l` — the source peek an expanded row
|
|
2861
|
+
* shows. Behind the login like every route; the path must resolve inside the project (the
|
|
2862
|
+
* process cwd, symlinks followed) and be a source file. Anything else is a 404. */
|
|
2863
|
+
async #source(ctx) {
|
|
2864
|
+
if (!this.dev) return new Response("Not found", { status: 404 });
|
|
2865
|
+
const p = ctx.url.searchParams.get("p") ?? "";
|
|
2866
|
+
const line = Math.max(1, Number(ctx.url.searchParams.get("l")) || 1);
|
|
2867
|
+
const to = Math.min(line + 60, Math.max(line, Number(ctx.url.searchParams.get("to")) || line));
|
|
2868
|
+
try {
|
|
2869
|
+
const fs = await import("node:fs");
|
|
2870
|
+
const path = await import("node:path");
|
|
2871
|
+
const real = fs.realpathSync(p);
|
|
2872
|
+
const root = fs.realpathSync(process.cwd());
|
|
2873
|
+
if (!real.startsWith(root + path.sep)) return new Response("Not found", { status: 404 });
|
|
2874
|
+
if (!SOURCE_EXT_RE.test(real)) return new Response("Not found", { status: 404 });
|
|
2875
|
+
const lines = fs.readFileSync(real, "utf8").split("\n");
|
|
2876
|
+
const start = Math.max(1, line - 4);
|
|
2877
|
+
const end = Math.min(lines.length, to + 4);
|
|
2878
|
+
return ctx.json({
|
|
2879
|
+
path: real,
|
|
2880
|
+
start,
|
|
2881
|
+
line,
|
|
2882
|
+
lines: lines.slice(start - 1, end)
|
|
2883
|
+
});
|
|
2884
|
+
} catch {
|
|
2885
|
+
return new Response("Not found", { status: 404 });
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
/** No open redirect — a `next` only bounces back into the profiler. */
|
|
2889
|
+
#safe_next(next) {
|
|
2890
|
+
const n = next ?? this.base;
|
|
2891
|
+
return n.startsWith(this.base) ? n : this.base;
|
|
2892
|
+
}
|
|
2893
|
+
/** Clear the session cookie and bounce to the dashboard. */
|
|
2894
|
+
#logout(ctx) {
|
|
2895
|
+
const res = ctx.redirect(this.base);
|
|
2896
|
+
res.headers.append("set-cookie", this.#session_cookie("", ctx.event));
|
|
2897
|
+
return res;
|
|
2898
|
+
}
|
|
2899
|
+
#dashboard(by) {
|
|
2900
|
+
const tag_keys = /* @__PURE__ */ new Set();
|
|
2901
|
+
for (const e of this.#ring) for (const k of Object.keys(e.tags ?? {})) tag_keys.add(k);
|
|
2902
|
+
return {
|
|
2903
|
+
base: this.base,
|
|
2904
|
+
recent: this.#ring,
|
|
2905
|
+
routes: route_aggregates(this.#ring, by && tag_keys.has(by) ? by : null),
|
|
2906
|
+
by: by && tag_keys.has(by) ? by : null,
|
|
2907
|
+
tag_keys: [...tag_keys].sort(),
|
|
2908
|
+
reports: [...this.#reports.values()].map((r) => r.meta).reverse(),
|
|
2909
|
+
top_fix: Object.fromEntries([...this.#reports.values()].filter((r) => r.patterns?.length).map((r) => {
|
|
2910
|
+
const p = r.patterns[0];
|
|
2911
|
+
const n = r.meta.trigger === "page" ? r.meta.runs?.length || 1 : 1;
|
|
2912
|
+
return [r.meta.id, {
|
|
2913
|
+
title: p.title,
|
|
2914
|
+
save_ms: round2(p.wait ? p.save_ms : p.save_ms / n),
|
|
2915
|
+
wait: !!p.wait,
|
|
2916
|
+
more: r.patterns.length - 1
|
|
2917
|
+
}];
|
|
2918
|
+
})),
|
|
2919
|
+
site_fixes: this.#site_fixes(),
|
|
2920
|
+
holes: hole_slots(this.#ring),
|
|
2921
|
+
recording: this.#recorder_busy || this.#recording_active(),
|
|
2922
|
+
dev: this.dev,
|
|
2923
|
+
rss_mb: Math.round(process.memoryUsage().rss / 1048576),
|
|
2924
|
+
inflight: this.#inflight,
|
|
2925
|
+
history: page_history([...this.#reports.values()].map((r) => r.meta), (id) => {
|
|
2926
|
+
const s = this.#reports.get(id);
|
|
2927
|
+
if (!s || s.meta.trigger !== "page") return void 0;
|
|
2928
|
+
try {
|
|
2929
|
+
return page_score_of(s.meta, this.#report_extras(s)).score;
|
|
2930
|
+
} catch {
|
|
2931
|
+
return;
|
|
2932
|
+
}
|
|
2933
|
+
}),
|
|
2934
|
+
trap: this.#trap ? {
|
|
2935
|
+
over: this.#trap.over,
|
|
2936
|
+
window_ms: this.#trap.window ?? 5e3,
|
|
2937
|
+
caught: this.#trap_caught,
|
|
2938
|
+
keep: this.#trap.keep ?? 3,
|
|
2939
|
+
armed: !!this.#trap_timer || this.#trap_running
|
|
2940
|
+
} : null,
|
|
2941
|
+
sampled: this.#sampled_summary(),
|
|
2942
|
+
background_note: this.#background_note
|
|
2943
|
+
};
|
|
2944
|
+
}
|
|
2945
|
+
#run_page(ctx) {
|
|
2946
|
+
const q = ctx.url.searchParams;
|
|
2947
|
+
let path = (q.get("p") ?? "").trim();
|
|
2948
|
+
if (path && !path.startsWith("/") && !path.includes("://")) path = "/" + path;
|
|
2949
|
+
if (!path.startsWith("/") || path.startsWith("//")) return error(400, "Give a path on this site, like /docs/overview.");
|
|
2950
|
+
const runs = clamp(Number(q.get("runs")) || 5, 1, 50);
|
|
2951
|
+
const format = q.get("format") === "ogp" ? "ogp" : "";
|
|
2952
|
+
const against_raw = q.get("against") ?? "";
|
|
2953
|
+
let against = "";
|
|
2954
|
+
for (const ch of against_raw) {
|
|
2955
|
+
const c = ch.charCodeAt(0);
|
|
2956
|
+
if (!(c >= 48 && c <= 57 || c >= 97 && c <= 122)) {
|
|
2957
|
+
against = "";
|
|
2958
|
+
break;
|
|
2959
|
+
}
|
|
2960
|
+
against += ch;
|
|
2961
|
+
}
|
|
2962
|
+
return {
|
|
2963
|
+
base: this.base,
|
|
2964
|
+
path,
|
|
2965
|
+
runs,
|
|
2966
|
+
format,
|
|
2967
|
+
...against && against.length <= 32 ? { against } : {}
|
|
2968
|
+
};
|
|
2969
|
+
}
|
|
2970
|
+
#window = null;
|
|
2971
|
+
async #window_sign(nonce) {
|
|
2972
|
+
const { createHmac } = await import("node:crypto");
|
|
2973
|
+
return createHmac("sha256", this.secret || "ogygia-dev-window").update("window:" + nonce).digest("hex").slice(0, 20);
|
|
2974
|
+
}
|
|
2975
|
+
/** A window id from a request, when its signature holds; else null. */
|
|
2976
|
+
async #window_id_of(request) {
|
|
2977
|
+
const raw = request.headers.get(WINDOW_HEADER);
|
|
2978
|
+
if (!raw || raw.length > 80) return null;
|
|
2979
|
+
const dot = raw.indexOf(".");
|
|
2980
|
+
if (dot <= 0) return null;
|
|
2981
|
+
const nonce = raw.slice(0, dot);
|
|
2982
|
+
return await this.#window_sign(nonce) === raw.slice(dot + 1) ? raw : null;
|
|
2983
|
+
}
|
|
2984
|
+
/** A request in a window: written to the store, awaited (see above). */
|
|
2985
|
+
async #window_note(entry, request) {
|
|
2986
|
+
if (!this.#store?.putVisit || entry.internal) return;
|
|
2987
|
+
const id = await this.#window_id_of(request);
|
|
2988
|
+
if (!id) return;
|
|
2989
|
+
const rec = {
|
|
2990
|
+
method: entry.method,
|
|
2991
|
+
path: entry.path,
|
|
2992
|
+
route: entry.route ?? null,
|
|
2993
|
+
ms: entry.ms,
|
|
2994
|
+
status: entry.status ?? null,
|
|
2995
|
+
cpu_ms: entry.cpu_ms,
|
|
2996
|
+
net_ms: entry.net_ms ?? null,
|
|
2997
|
+
net_count: entry.net_count ?? 0,
|
|
2998
|
+
at: Date.now(),
|
|
2999
|
+
instance: this.#instance_id
|
|
3000
|
+
};
|
|
3001
|
+
try {
|
|
3002
|
+
await this.#store.putVisit({
|
|
3003
|
+
key: `${WINDOW_PAGE_PREFIX}${id}\0${rec.at}\0${Math.random().toString(36).slice(2, 8)}`,
|
|
3004
|
+
page: WINDOW_PAGE_PREFIX + id,
|
|
3005
|
+
at: rec.at,
|
|
3006
|
+
visit: JSON.stringify(rec)
|
|
3007
|
+
});
|
|
3008
|
+
} catch {}
|
|
3009
|
+
}
|
|
3010
|
+
/** The requests every instance wrote for a window. */
|
|
3011
|
+
async #window_requests(id) {
|
|
3012
|
+
if (!this.#store?.listVisits) return [];
|
|
3013
|
+
try {
|
|
3014
|
+
return (await this.#store.listVisits(WINDOW_PAGE_PREFIX + id, MAX_VISITS_PER_KEY)).map((r) => JSON.parse(r.visit)).sort((a, b) => a.at - b.at);
|
|
3015
|
+
} catch {
|
|
3016
|
+
return [];
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
/** This instance, told apart from the others in a window's requests. */
|
|
3020
|
+
#instance_id = Math.random().toString(36).slice(2, 10);
|
|
3021
|
+
async #window_start(ctx) {
|
|
3022
|
+
const busy = (message) => new Response(JSON.stringify({
|
|
3023
|
+
busy: true,
|
|
3024
|
+
message
|
|
3025
|
+
}), {
|
|
3026
|
+
status: 409,
|
|
3027
|
+
headers: {
|
|
3028
|
+
"content-type": "application/json; charset=utf-8",
|
|
3029
|
+
"retry-after": "2",
|
|
3030
|
+
"cache-control": "no-store"
|
|
3031
|
+
}
|
|
3032
|
+
});
|
|
3033
|
+
if (this.#window) return busy("A window is already recording: stop it first.");
|
|
3034
|
+
if (this.#recorder_busy && this.#trap_running) {
|
|
3035
|
+
this.#page_waiting++;
|
|
3036
|
+
try {
|
|
3037
|
+
const until = Date.now() + PAGE_WAITS_FOR_BACKGROUND_MS;
|
|
3038
|
+
while (this.#recorder_busy && this.#trap_running && Date.now() < until) await new Promise((r) => setTimeout(r, 50));
|
|
3039
|
+
} finally {
|
|
3040
|
+
this.#page_waiting--;
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
if (!this.#try_acquire_recorder()) return busy("Another recording holds the recorder. Try again in a moment.");
|
|
3044
|
+
this.#recording_since = Date.now();
|
|
3045
|
+
this.#trap_running = true;
|
|
3046
|
+
this.#ensure_net?.();
|
|
3047
|
+
let stop;
|
|
3048
|
+
const stopped = new Promise((r) => stop = r);
|
|
3049
|
+
const nonce = Math.random().toString(36).slice(2, 12) + Date.now().toString(36);
|
|
3050
|
+
const id = `${nonce}.${await this.#window_sign(nonce)}`;
|
|
3051
|
+
const timer = setTimeout(() => {
|
|
3052
|
+
stop();
|
|
3053
|
+
done.finally(() => {
|
|
3054
|
+
if (this.#window?.id === id) this.#window_release();
|
|
3055
|
+
});
|
|
3056
|
+
}, WINDOW_MAX_MS);
|
|
3057
|
+
timer.unref?.();
|
|
3058
|
+
const done = this.#capture_window(Math.min(this.sample_interval, 1e3), () => stopped, { light: true });
|
|
3059
|
+
done.catch(() => this.#window_release());
|
|
3060
|
+
this.#window = {
|
|
3061
|
+
id,
|
|
3062
|
+
stop,
|
|
3063
|
+
done,
|
|
3064
|
+
timer
|
|
3065
|
+
};
|
|
3066
|
+
return ctx.json({
|
|
3067
|
+
ok: true,
|
|
3068
|
+
id,
|
|
3069
|
+
header: WINDOW_HEADER,
|
|
3070
|
+
shared: !!(this.#store?.putVisit && this.#store.listVisits),
|
|
3071
|
+
max_ms: WINDOW_MAX_MS
|
|
3072
|
+
});
|
|
3073
|
+
}
|
|
3074
|
+
#window_release() {
|
|
3075
|
+
if (this.#window) clearTimeout(this.#window.timer);
|
|
3076
|
+
this.#window = null;
|
|
3077
|
+
this.#trap_running = false;
|
|
3078
|
+
this.#recording_since = 0;
|
|
3079
|
+
this.#release_als();
|
|
3080
|
+
this.#release_recorder();
|
|
3081
|
+
}
|
|
3082
|
+
async #window_stop(ctx) {
|
|
3083
|
+
const asked = ctx.event ? await this.#window_id_of(ctx.event.request) : null;
|
|
3084
|
+
const w = this.#window && (!asked || this.#window.id === asked) ? this.#window : null;
|
|
3085
|
+
if (!w) {
|
|
3086
|
+
if (asked && this.#store?.listVisits) {
|
|
3087
|
+
const reqs = await this.#window_requests(asked);
|
|
3088
|
+
const busy = round2(reqs.reduce((a, r) => a + (r.cpu_ms || 0), 0));
|
|
3089
|
+
return ctx.json({
|
|
3090
|
+
kind: "window",
|
|
3091
|
+
id: null,
|
|
3092
|
+
target: {
|
|
3093
|
+
page: "",
|
|
3094
|
+
runs: []
|
|
3095
|
+
},
|
|
3096
|
+
score: null,
|
|
3097
|
+
forecast: null,
|
|
3098
|
+
findings: [],
|
|
3099
|
+
assets: null,
|
|
3100
|
+
links: {
|
|
3101
|
+
html: "",
|
|
3102
|
+
json: ""
|
|
3103
|
+
},
|
|
3104
|
+
summary: { busy_ms: busy },
|
|
3105
|
+
cpu: "the instance that started the window sampled it; this one only gathered the requests",
|
|
3106
|
+
window: {
|
|
3107
|
+
ms: reqs.length ? reqs[reqs.length - 1].at - reqs[0].at : 0,
|
|
3108
|
+
requests: reqs,
|
|
3109
|
+
instances: new Set(reqs.map((r) => r.instance)).size
|
|
3110
|
+
}
|
|
3111
|
+
});
|
|
3112
|
+
}
|
|
3113
|
+
return ctx.json({ error: "No window is recording (start one with POST /window/start)." }, { status: 400 });
|
|
3114
|
+
}
|
|
3115
|
+
w.stop();
|
|
3116
|
+
let cap;
|
|
3117
|
+
try {
|
|
3118
|
+
cap = await w.done;
|
|
3119
|
+
} catch (e) {
|
|
3120
|
+
this.#window_release();
|
|
3121
|
+
return ctx.json({ error: `The window failed: ${e instanceof Error ? e.message : String(e)}` }, { status: 500 });
|
|
3122
|
+
}
|
|
3123
|
+
const own = `${this.base}/`;
|
|
3124
|
+
const shared = await this.#window_requests(w.id);
|
|
3125
|
+
const others = shared.filter((e) => e.instance !== this.#instance_id && !e.path.startsWith(own) && e.path !== this.base);
|
|
3126
|
+
const requests = [...this.#ring.filter((e) => !e.internal && e.ts >= cap.t0 && e.ts <= cap.t1 && !e.path.startsWith(own) && e.path !== this.base).map((e) => ({
|
|
3127
|
+
method: e.method,
|
|
3128
|
+
path: e.path,
|
|
3129
|
+
route: e.route ?? null,
|
|
3130
|
+
ms: round2(e.ms),
|
|
3131
|
+
status: e.status ?? null,
|
|
3132
|
+
cpu_ms: e.cpu_ms,
|
|
3133
|
+
instance: this.#instance_id
|
|
3134
|
+
})), ...others];
|
|
3135
|
+
try {
|
|
3136
|
+
const id = await this.#finish_report(cap, {
|
|
3137
|
+
trigger: "window",
|
|
3138
|
+
...requests[0] ? { request: {
|
|
3139
|
+
method: requests[0].method,
|
|
3140
|
+
path: requests[0].path,
|
|
3141
|
+
route: requests[0].route,
|
|
3142
|
+
ms: requests[0].ms
|
|
3143
|
+
} } : {}
|
|
3144
|
+
});
|
|
3145
|
+
const s = this.#reports.get(id);
|
|
3146
|
+
return ctx.json({
|
|
3147
|
+
...report_json(s.analysis, s.meta, this.base, this.#report_extras(s)),
|
|
3148
|
+
window: {
|
|
3149
|
+
ms: round2(cap.t1 - cap.t0),
|
|
3150
|
+
requests,
|
|
3151
|
+
...shared.length ? { instances: new Set(requests.map((r) => r.instance)).size } : {}
|
|
3152
|
+
}
|
|
3153
|
+
});
|
|
3154
|
+
} finally {
|
|
3155
|
+
this.#window_release();
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
#reset(ctx) {
|
|
3159
|
+
this.#recording_since = 0;
|
|
3160
|
+
this.#release_als();
|
|
3161
|
+
this.#recorder_busy = false;
|
|
3162
|
+
return ctx.redirect(this.base);
|
|
3163
|
+
}
|
|
3164
|
+
async #record_page(ctx, replay) {
|
|
3165
|
+
const event = ctx.event;
|
|
3166
|
+
if (this.#recorder_busy && this.#trap_running) {
|
|
3167
|
+
this.#page_waiting++;
|
|
3168
|
+
try {
|
|
3169
|
+
const until = Date.now() + PAGE_WAITS_FOR_BACKGROUND_MS;
|
|
3170
|
+
while (this.#recorder_busy && this.#trap_running && Date.now() < until) await new Promise((r) => setTimeout(r, 50));
|
|
3171
|
+
} finally {
|
|
3172
|
+
this.#page_waiting--;
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
if (!this.#try_acquire_recorder()) return new Response(JSON.stringify({
|
|
3176
|
+
busy: true,
|
|
3177
|
+
message: "A profile is already running on this worker. Waiting for it to finish (or for a free worker)."
|
|
3178
|
+
}), {
|
|
3179
|
+
status: 409,
|
|
3180
|
+
headers: {
|
|
3181
|
+
"content-type": "application/json; charset=utf-8",
|
|
3182
|
+
"retry-after": "2",
|
|
3183
|
+
"cache-control": "no-store"
|
|
3184
|
+
}
|
|
3185
|
+
});
|
|
3186
|
+
const q = ctx.url.searchParams;
|
|
3187
|
+
this.#recording_since = Date.now();
|
|
3188
|
+
const watchdog = setTimeout(() => {
|
|
3189
|
+
this.#recording_since = 0;
|
|
3190
|
+
this.#release_als();
|
|
3191
|
+
}, 32e3);
|
|
3192
|
+
watchdog.unref?.();
|
|
3193
|
+
this.#ensure_net?.();
|
|
3194
|
+
try {
|
|
3195
|
+
const path = replay?.path ?? q.get("p") ?? "";
|
|
3196
|
+
if (!path.startsWith("/") || path.startsWith("//")) return error(400, "Give a path on this site, like /docs/overview.");
|
|
3197
|
+
const runs = clamp(Number(q.get("runs")) || 5, 1, 50);
|
|
3198
|
+
const interval = clamp(Number(q.get("interval")) || 200, 50, 1e4);
|
|
3199
|
+
let heap_now = null;
|
|
3200
|
+
try {
|
|
3201
|
+
heap_now = (await import("node:v8")).getHeapStatistics();
|
|
3202
|
+
} catch {
|
|
3203
|
+
heap_now = null;
|
|
3204
|
+
}
|
|
3205
|
+
if (heap_now && heap_now.heap_size_limit > 0 && heap_now.used_heap_size / heap_now.heap_size_limit >= HEAP_START) return error(503, `The server's heap is ${Math.round(heap_now.used_heap_size / heap_now.heap_size_limit * 100)}% full (${Math.round(heap_now.used_heap_size / 1048576)} of ${Math.round(heap_now.heap_size_limit / 1048576)} MB): profiling now could run it out of memory and end the process. Something keeps what each render makes (the last profile's Kept per render names the line). Restart the server, then profile once.`);
|
|
3206
|
+
const work_budget = serverless_work_budget_ms();
|
|
3207
|
+
const requests_before = Math.max(0, this.#requests_seen - 1);
|
|
3208
|
+
const budget_deadline = Number.isFinite(work_budget) ? Date.now() + work_budget : Infinity;
|
|
3209
|
+
const request_deadline = Date.now() + detect_request_budget_ms();
|
|
3210
|
+
const deadline = Math.min(budget_deadline, Date.now() + RECORDING_HARD_CAP_MS);
|
|
3211
|
+
const fetch_render = (p, extra) => {
|
|
3212
|
+
const left = deadline - Date.now();
|
|
3213
|
+
const ms = Number.isFinite(left) ? Math.max(1, Math.min(PER_RENDER_TIMEOUT_MS, left)) : PER_RENDER_TIMEOUT_MS;
|
|
3214
|
+
return with_timeout(event.fetch(p, { headers: {
|
|
3215
|
+
"sec-fetch-mode": "navigate",
|
|
3216
|
+
"sec-fetch-dest": "document",
|
|
3217
|
+
accept: "text/html",
|
|
3218
|
+
...replay?.headers,
|
|
3219
|
+
...extra,
|
|
3220
|
+
"x-og-profiler-internal": "1"
|
|
3221
|
+
} }), ms, "render timed out (the page or one of its upstream calls did not return in time)");
|
|
3222
|
+
};
|
|
3223
|
+
let target = path;
|
|
3224
|
+
let redirected_from;
|
|
3225
|
+
/** a hop was a prerendered redirect's stub (a meta refresh answered 200) */
|
|
3226
|
+
let redirect_stub = false;
|
|
3227
|
+
let warmup_ms;
|
|
3228
|
+
let warm_status = 0;
|
|
3229
|
+
let warm_bytes = 0;
|
|
3230
|
+
let cold_cap;
|
|
3231
|
+
const want_cold = q.get("cold") !== "0";
|
|
3232
|
+
mem_mark("before the warm-up render");
|
|
3233
|
+
for (let hop = 0; hop < 5; hop++) {
|
|
3234
|
+
let t = performance.now();
|
|
3235
|
+
let res;
|
|
3236
|
+
let body = "";
|
|
3237
|
+
try {
|
|
3238
|
+
if (hop === 0 && want_cold) {
|
|
3239
|
+
let t1 = 0;
|
|
3240
|
+
cold_cap = await this.#capture_window(interval, async () => {
|
|
3241
|
+
t = performance.now();
|
|
3242
|
+
res = await fetch_render(target);
|
|
3243
|
+
body = await res.text();
|
|
3244
|
+
t1 = performance.now();
|
|
3245
|
+
}, { light: true });
|
|
3246
|
+
if (res) warmup_ms = round2(t1 - t);
|
|
3247
|
+
} else {
|
|
3248
|
+
res = await fetch_render(target);
|
|
3249
|
+
body = await res.text();
|
|
3250
|
+
warmup_ms = round2(performance.now() - t);
|
|
3251
|
+
}
|
|
3252
|
+
} catch {
|
|
3253
|
+
break;
|
|
3254
|
+
}
|
|
3255
|
+
if (!res) break;
|
|
3256
|
+
warm_status = res.status;
|
|
3257
|
+
warm_bytes = body.length;
|
|
3258
|
+
const stub = res.ok && !res.redirected ? refresh_target(body, event.url.origin) : null;
|
|
3259
|
+
const next = res.redirected ? new URL(res.url).pathname + new URL(res.url).search : res.status >= 300 && res.status < 400 ? (() => {
|
|
3260
|
+
const loc = res.headers.get("location");
|
|
3261
|
+
if (!loc) return null;
|
|
3262
|
+
const u = new URL(loc, event.url.origin);
|
|
3263
|
+
return u.pathname + u.search;
|
|
3264
|
+
})() : stub;
|
|
3265
|
+
if (!next || next === target) break;
|
|
3266
|
+
redirected_from ??= path;
|
|
3267
|
+
if (stub && next === stub) redirect_stub = true;
|
|
3268
|
+
target = next;
|
|
3269
|
+
}
|
|
3270
|
+
const run_ms = [];
|
|
3271
|
+
const run_windows = [];
|
|
3272
|
+
let run_status = warm_status;
|
|
3273
|
+
let run_cache_control;
|
|
3274
|
+
let run_sets_cookie = false;
|
|
3275
|
+
let run_bytes = warm_bytes;
|
|
3276
|
+
let last_body = "";
|
|
3277
|
+
/** the first render's document, set against the last: what changes between renders */
|
|
3278
|
+
let first_body = "";
|
|
3279
|
+
const body_prints = [];
|
|
3280
|
+
let last_chunks = [];
|
|
3281
|
+
let budget_note;
|
|
3282
|
+
let slowest = warmup_ms ?? 0;
|
|
3283
|
+
const coverage_reserve = () => Math.max(slowest * 4.5, 300);
|
|
3284
|
+
const gc_attr = q.get("gc") !== "0";
|
|
3285
|
+
const retention_reserve = () => gc_attr && this.want_heap ? slowest * 1.5 + 500 : 0;
|
|
3286
|
+
let v8_stats = null;
|
|
3287
|
+
try {
|
|
3288
|
+
v8_stats = (await import("node:v8")).getHeapStatistics;
|
|
3289
|
+
} catch {
|
|
3290
|
+
v8_stats = null;
|
|
3291
|
+
}
|
|
3292
|
+
const heap_fill = () => {
|
|
3293
|
+
const s = v8_stats?.();
|
|
3294
|
+
return s && s.heap_size_limit > 0 ? s.used_heap_size / s.heap_size_limit : 0;
|
|
3295
|
+
};
|
|
3296
|
+
const heap_skipped = [];
|
|
3297
|
+
const heap_guard = () => {
|
|
3298
|
+
const s = v8_stats?.();
|
|
3299
|
+
return s ? {
|
|
3300
|
+
used_mb: Math.round(s.used_heap_size / 1048576),
|
|
3301
|
+
limit_mb: Math.round(s.heap_size_limit / 1048576),
|
|
3302
|
+
skipped: heap_skipped
|
|
3303
|
+
} : void 0;
|
|
3304
|
+
};
|
|
3305
|
+
this.#kept?.keep(true);
|
|
3306
|
+
mem_mark("before the timed renders");
|
|
3307
|
+
const cap = await this.#capture_window(interval, async () => {
|
|
3308
|
+
const path_only = target.split("?")[0];
|
|
3309
|
+
const mixed = (w) => this.#ring.some((e) => !e.internal && e.pt !== void 0 && e.path === path_only && e.pt < w.end && (e.ms > 0 ? e.pt + e.ms : Infinity) > w.start);
|
|
3310
|
+
const want_clean = Math.min(2, runs);
|
|
3311
|
+
for (let i = 0; i < runs * 2; i++) {
|
|
3312
|
+
if (i >= runs && run_windows.filter((w) => !mixed(w)).length >= want_clean) break;
|
|
3313
|
+
if (i > 0 && heap_fill() >= HEAP_STOP) {
|
|
3314
|
+
if (i < runs) {
|
|
3315
|
+
budget_note = `Ran ${i} of ${runs} renders — stopped: the server's heap was ${Math.round(heap_fill() * 100)}% full, and each render of this page keeps more of it.`;
|
|
3316
|
+
heap_skipped.push(`${runs - i} of the timed renders`);
|
|
3317
|
+
}
|
|
3318
|
+
break;
|
|
3319
|
+
}
|
|
3320
|
+
if (i > 0 && Date.now() + slowest + retention_reserve() + coverage_reserve() > deadline) {
|
|
3321
|
+
const serverless = Number.isFinite(budget_deadline) && deadline === budget_deadline;
|
|
3322
|
+
const limit_ms = serverless ? work_budget : RECORDING_HARD_CAP_MS;
|
|
3323
|
+
const budget_label = limit_ms >= 1e3 ? `${Math.round(limit_ms / 1e3)} s` : `${Math.round(limit_ms)} ms`;
|
|
3324
|
+
if (i < runs) budget_note = `Ran ${i} of ${runs} renders — trimmed to fit the ` + (serverless ? `${budget_label} serverless budget` : `${budget_label} limit on one recording`) + ` (the page renders in ~${Math.round(warmup_ms ?? 0)} ms). Fewer runs, same accuracy per run.`;
|
|
3325
|
+
break;
|
|
3326
|
+
}
|
|
3327
|
+
const t = performance.now();
|
|
3328
|
+
let res;
|
|
3329
|
+
try {
|
|
3330
|
+
res = await fetch_render(target);
|
|
3331
|
+
} catch {
|
|
3332
|
+
budget_note = i > 0 ? `Ran ${i} of ${runs} renders — stopped early, a later render hung (the page or an upstream call did not return within ${Math.round(PER_RENDER_TIMEOUT_MS / 1e3)}s).` : void 0;
|
|
3333
|
+
break;
|
|
3334
|
+
}
|
|
3335
|
+
const { text: body, chunks } = await read_timed(res, t);
|
|
3336
|
+
const done = performance.now();
|
|
3337
|
+
run_status = res.status;
|
|
3338
|
+
run_bytes = body.length;
|
|
3339
|
+
run_cache_control = res.headers.get("cache-control") ?? void 0;
|
|
3340
|
+
if (res.headers.has("set-cookie")) run_sets_cookie = true;
|
|
3341
|
+
body_prints.push(`${fnv1a32(body)}:${body.length}`);
|
|
3342
|
+
if (!first_body) first_body = body;
|
|
3343
|
+
last_body = body;
|
|
3344
|
+
last_chunks = chunks;
|
|
3345
|
+
run_ms.push(round2(done - t));
|
|
3346
|
+
slowest = Math.max(slowest, done - t);
|
|
3347
|
+
run_windows.push({
|
|
3348
|
+
start: t,
|
|
3349
|
+
end: done
|
|
3350
|
+
});
|
|
3351
|
+
await new Promise((r) => setImmediate(r));
|
|
3352
|
+
}
|
|
3353
|
+
}, { gc_attr });
|
|
3354
|
+
if (run_ms.length === 0) return error(504, "The page did not return in time to profile it. It (or an upstream call it makes) is either hung or slower than the recording budget — try a lighter path, or check that page for a stuck request.");
|
|
3355
|
+
const target_path = target.split("?")[0];
|
|
3356
|
+
const own_calls = /* @__PURE__ */ new Set();
|
|
3357
|
+
for (const c of cap.net) try {
|
|
3358
|
+
own_calls.add(new URL(c.url, "http://x").pathname);
|
|
3359
|
+
} catch {}
|
|
3360
|
+
const outside = this.#ring.filter((e) => !e.internal && e.pt !== void 0 && (e.path === target_path || !own_calls.has(e.path.split("?")[0]) && !e.path.startsWith(this.base)));
|
|
3361
|
+
const mixed_at = run_windows.map((w) => outside.some((e) => e.pt < w.end && (e.ms > 0 ? e.pt + e.ms : Infinity) > w.start));
|
|
3362
|
+
let runs_set_aside = 0;
|
|
3363
|
+
if (mixed_at.some(Boolean) && mixed_at.some((m) => !m)) {
|
|
3364
|
+
const aside = [];
|
|
3365
|
+
for (let i = run_windows.length - 1; i >= 0; i--) {
|
|
3366
|
+
if (!mixed_at[i]) continue;
|
|
3367
|
+
aside.push(run_windows[i]);
|
|
3368
|
+
run_windows.splice(i, 1);
|
|
3369
|
+
run_ms.splice(i, 1);
|
|
3370
|
+
body_prints.splice(i, 1);
|
|
3371
|
+
runs_set_aside++;
|
|
3372
|
+
}
|
|
3373
|
+
cap.set_aside = aside;
|
|
3374
|
+
}
|
|
3375
|
+
const stable = stable_answers(cap.net, run_windows);
|
|
3376
|
+
this.#kept?.keep(false);
|
|
3377
|
+
const almost = /* @__PURE__ */ new Map();
|
|
3378
|
+
if (this.#kept) for (const key of varied_answers(cap.net, run_windows)) {
|
|
3379
|
+
const d = this.#kept.diff(key);
|
|
3380
|
+
if (d && d.size >= 64 && d.bytes <= Math.max(48, d.size * .02)) almost.set(key, d.text);
|
|
3381
|
+
}
|
|
3382
|
+
mem_mark("after the timed renders");
|
|
3383
|
+
let answers;
|
|
3384
|
+
if (stable.size && this.#kept) {
|
|
3385
|
+
const state = {
|
|
3386
|
+
token: `${fnv1a32(`${Math.random()}:${Date.now()}`).toString(36)}${fnv1a32(String(performance.now())).toString(36)}`,
|
|
3387
|
+
keys: stable
|
|
3388
|
+
};
|
|
3389
|
+
this.#answers = state;
|
|
3390
|
+
try {
|
|
3391
|
+
const ms = [];
|
|
3392
|
+
for (let i = 0; i < ANSWER_RENDERS; i++) {
|
|
3393
|
+
if (Date.now() + slowest + retention_reserve() + coverage_reserve() > deadline) break;
|
|
3394
|
+
const t = performance.now();
|
|
3395
|
+
const res = await fetch_render(target, { [ANSWERS_HEADER]: state.token });
|
|
3396
|
+
await res.text();
|
|
3397
|
+
if (res.status !== run_status) break;
|
|
3398
|
+
ms.push(round2(performance.now() - t));
|
|
3399
|
+
}
|
|
3400
|
+
if (ms.length) answers = {
|
|
3401
|
+
runs_ms: ms,
|
|
3402
|
+
calls: stable.size
|
|
3403
|
+
};
|
|
3404
|
+
} catch {
|
|
3405
|
+
answers = void 0;
|
|
3406
|
+
} finally {
|
|
3407
|
+
this.#answers = null;
|
|
3408
|
+
this.#kept.drop();
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
const wait_runs = {};
|
|
3412
|
+
for (const c of cap.net) {
|
|
3413
|
+
if (c.ms < 0) continue;
|
|
3414
|
+
const ri = run_windows.findIndex((w) => c.start >= w.start && c.start <= w.end);
|
|
3415
|
+
if (ri === -1) continue;
|
|
3416
|
+
const k = call_group(label_call(c.method, c.url));
|
|
3417
|
+
(wait_runs[k] ??= Array.from({ length: run_windows.length }, () => 0))[ri] += c.ms + (c.body_ms ?? 0);
|
|
3418
|
+
}
|
|
3419
|
+
mark_left_each_run(cap.io_ops, run_windows);
|
|
3420
|
+
cap.window = scope_net_to_one_run(cap, run_windows) ?? void 0;
|
|
3421
|
+
cap.runs = run_windows;
|
|
3422
|
+
let cold;
|
|
3423
|
+
mem_mark("after the answer renders");
|
|
3424
|
+
const cold_route = this.#ring.find((e) => e.internal && e.path === target_path && e.route)?.route;
|
|
3425
|
+
if (cold_cap && warmup_ms !== void 0) try {
|
|
3426
|
+
const p = cold_cap.profile;
|
|
3427
|
+
const whole = {
|
|
3428
|
+
start: cold_cap.perf_start,
|
|
3429
|
+
end: cold_cap.perf_start + (p.endTime - p.startTime) / 1e3
|
|
3430
|
+
};
|
|
3431
|
+
const a0 = analyze(p, await this.#make_resolver(), void 0, {
|
|
3432
|
+
perf_start: cold_cap.perf_start,
|
|
3433
|
+
window: whole,
|
|
3434
|
+
calls: [],
|
|
3435
|
+
runs: [whole],
|
|
3436
|
+
...cold_route ? { route: cold_route } : {}
|
|
3437
|
+
}, void 0, this.#module_hint);
|
|
3438
|
+
const owners = Object.entries(a0.owner_runs_ms ?? {}).map(([label, ms]) => ({
|
|
3439
|
+
label,
|
|
3440
|
+
ms: ms[0]
|
|
3441
|
+
})).filter((o) => o.ms >= .5).sort((x, y) => y.ms - x.ms).slice(0, 40);
|
|
3442
|
+
cold = {
|
|
3443
|
+
ms: warmup_ms,
|
|
3444
|
+
busy_ms: a0.busy_ms,
|
|
3445
|
+
files: a0.files.filter((f) => f.category !== "idle" && f.category !== "gc" && f.category !== "profiler" && f.self_ms >= .5).slice(0, 40).map((f) => ({
|
|
3446
|
+
file: f.key,
|
|
3447
|
+
category: f.category,
|
|
3448
|
+
ms: f.self_ms
|
|
3449
|
+
})),
|
|
3450
|
+
...owners.length ? { owners } : {},
|
|
3451
|
+
calls: cold_cap.net.length
|
|
3452
|
+
};
|
|
3453
|
+
} catch {
|
|
3454
|
+
cold = void 0;
|
|
3455
|
+
}
|
|
3456
|
+
mem_mark("after the answer renders and the cold analysis");
|
|
3457
|
+
let retained;
|
|
3458
|
+
let retain_ms = 0;
|
|
3459
|
+
const heap_ok = heap_fill() < HEAP_EXTRAS;
|
|
3460
|
+
if (gc_attr && this.want_heap && !heap_ok) heap_skipped.push("the render that measures what a render keeps");
|
|
3461
|
+
if (gc_attr && this.want_heap && heap_ok && Date.now() + retention_reserve() + coverage_reserve() < deadline) try {
|
|
3462
|
+
const t = Date.now();
|
|
3463
|
+
retained = await this.#retention_pass(async () => {
|
|
3464
|
+
await fetch_render(target).then((r) => r.text());
|
|
3465
|
+
});
|
|
3466
|
+
retain_ms = Date.now() - t;
|
|
3467
|
+
} catch {
|
|
3468
|
+
retained = void 0;
|
|
3469
|
+
}
|
|
3470
|
+
mem_mark("after the retention render");
|
|
3471
|
+
let growth;
|
|
3472
|
+
if (retained && retained.total_bytes >= 262144) {
|
|
3473
|
+
const each = Math.max(retain_ms, slowest + 300);
|
|
3474
|
+
const room = deadline - Date.now() - coverage_reserve() - 1500;
|
|
3475
|
+
const s = v8_stats?.();
|
|
3476
|
+
const heap_n = s ? Math.floor((s.heap_size_limit * HEAP_STOP - s.used_heap_size) / retained.total_bytes) : Infinity;
|
|
3477
|
+
const n = Math.min(6, Math.floor(room / each), heap_n);
|
|
3478
|
+
if (heap_n < 3) heap_skipped.push("the growth check (a few more renders, to tell a leak from a cache filling)");
|
|
3479
|
+
if (n >= 3) try {
|
|
3480
|
+
growth = await this.#growth_check(async () => {
|
|
3481
|
+
await fetch_render(target).then((r) => r.text());
|
|
3482
|
+
}, n, Date.now() + room);
|
|
3483
|
+
} catch {
|
|
3484
|
+
growth = void 0;
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
mem_mark("after the retention and growth renders");
|
|
3488
|
+
const counted = heap_fill() < HEAP_STOP ? await this.#count_calls(async () => {
|
|
3489
|
+
await fetch_render(target).then((r) => r.text());
|
|
3490
|
+
}, true) : (heap_skipped.push("the call-count render (the ×N counts)"), { counts: {} });
|
|
3491
|
+
cap.call_counts = counted.counts;
|
|
3492
|
+
mem_mark("after the coverage render");
|
|
3493
|
+
const app_fetch = async (u) => {
|
|
3494
|
+
try {
|
|
3495
|
+
const r = await event.fetch(u, { headers: { "x-og-profiler-internal": "1" } });
|
|
3496
|
+
if (r.ok) return r;
|
|
3497
|
+
} catch {}
|
|
3498
|
+
return fetch(new URL(u, event.url.origin), { headers: { "x-og-profiler-internal": "1" } });
|
|
3499
|
+
};
|
|
3500
|
+
const id = await this.#finish_report(cap, {
|
|
3501
|
+
trigger: "page",
|
|
3502
|
+
page: target,
|
|
3503
|
+
redirected_from,
|
|
3504
|
+
...redirect_stub ? { redirect_stub: true } : {},
|
|
3505
|
+
...process.env.AWS_LAMBDA_FUNCTION_NAME && !process.env.VERCEL && !process.env.NETLIFY ? {
|
|
3506
|
+
lambda: true,
|
|
3507
|
+
...Number(process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE) > 0 ? { lambda_mb: Number(process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE) } : {}
|
|
3508
|
+
} : {},
|
|
3509
|
+
heap_limit_mb: Math.round((await import("node:v8")).getHeapStatistics().heap_size_limit / 1048576),
|
|
3510
|
+
instance: {
|
|
3511
|
+
first_request_ms: round2(this.#first_request_pt ?? 0),
|
|
3512
|
+
node_ms: round2(performance.nodeTiming?.bootstrapComplete ?? 0),
|
|
3513
|
+
age_s: Math.round(performance.now() / 100) / 10,
|
|
3514
|
+
requests_before
|
|
3515
|
+
},
|
|
3516
|
+
warmup_ms,
|
|
3517
|
+
run_status,
|
|
3518
|
+
run_bytes,
|
|
3519
|
+
...run_cache_control ? { run_cache_control } : {},
|
|
3520
|
+
...run_sets_cookie ? { run_sets_cookie: true } : {},
|
|
3521
|
+
...(() => {
|
|
3522
|
+
const path = target.split("?")[0];
|
|
3523
|
+
for (let k = this.#ring.length - 1; k >= 0; k--) {
|
|
3524
|
+
const e = this.#ring[k];
|
|
3525
|
+
if (e.path === path) return e.route ? { run_route: e.route } : {};
|
|
3526
|
+
}
|
|
3527
|
+
return run_status === 200 && !this.dev ? { run_static: true } : {};
|
|
3528
|
+
})(),
|
|
3529
|
+
budget_note,
|
|
3530
|
+
...heap_skipped.length ? { heap_guard: heap_guard() } : {},
|
|
3531
|
+
runs: run_ms,
|
|
3532
|
+
...runs_set_aside ? { runs_set_aside } : {},
|
|
3533
|
+
...body_prints.length >= 2 && body_prints.every((p) => p === body_prints[0]) ? { same_document: true } : {},
|
|
3534
|
+
...cold ? { cold } : {},
|
|
3535
|
+
...answers ? { answers } : {}
|
|
3536
|
+
}, app_fetch);
|
|
3537
|
+
mem_mark("after the report was built");
|
|
3538
|
+
const s = this.#reports.get(id);
|
|
3539
|
+
if (retained) s.retained = retained;
|
|
3540
|
+
if (growth) s.growth = growth;
|
|
3541
|
+
if (!s.meta.same_document && first_body && last_body && run_ms.length >= 2) try {
|
|
3542
|
+
const rows = island_rows_of(s.meta);
|
|
3543
|
+
const name_of = (entry) => {
|
|
3544
|
+
const tail = entry.startsWith(".") ? entry.slice(1) : entry;
|
|
3545
|
+
const r = rows.find((x) => x.entry === tail || x.entry.endsWith(tail));
|
|
3546
|
+
return r ? island_name(r) : void 0;
|
|
3547
|
+
};
|
|
3548
|
+
const diff = doc_diff(first_body, last_body, name_of);
|
|
3549
|
+
if (diff) s.meta.doc_diff = diff;
|
|
3550
|
+
} catch {}
|
|
3551
|
+
if (last_body) {
|
|
3552
|
+
try {
|
|
3553
|
+
s.strip = byte_strip(last_body);
|
|
3554
|
+
if (last_chunks.length > 1) {
|
|
3555
|
+
s.strip.chunks = last_chunks;
|
|
3556
|
+
const tail = stream_tail(last_body, s.strip, last_chunks);
|
|
3557
|
+
if (tail) s.strip.tail = tail;
|
|
3558
|
+
}
|
|
3559
|
+
} catch {}
|
|
3560
|
+
if (this.dev) s.assets_missing = "the dev server serves modules one by one, so its JS says nothing about a build: profile a build (preview or deploy) to weigh the page";
|
|
3561
|
+
else try {
|
|
3562
|
+
const extra = [];
|
|
3563
|
+
for (const r of island_rows_of(s.meta)) {
|
|
3564
|
+
const lazy = r.wake !== "load" && r.wake !== "idle";
|
|
3565
|
+
for (const u of [r.module_url, ...r.hints]) if (u) extra.push({
|
|
3566
|
+
url: u,
|
|
3567
|
+
kind: "script",
|
|
3568
|
+
blocking: false,
|
|
3569
|
+
via: "island",
|
|
3570
|
+
...lazy ? { lazy: true } : {}
|
|
3571
|
+
});
|
|
3572
|
+
}
|
|
3573
|
+
const { gzipSync } = await import("node:zlib");
|
|
3574
|
+
s.assets = await weigh_assets({
|
|
3575
|
+
html: last_body,
|
|
3576
|
+
page_url: new URL(target, event.url.origin).href,
|
|
3577
|
+
fetch_url: app_fetch,
|
|
3578
|
+
cache: this.#asset_weights,
|
|
3579
|
+
gzip: (b) => gzipSync(b, { level: 6 }).byteLength,
|
|
3580
|
+
budget_ms: q.get("weigh") === "full" ? Math.max(500, Math.min(12e4, request_deadline - Date.now() - 4e3)) : Math.max(500, Math.min(6e3, request_deadline - Date.now() - 8e3)),
|
|
3581
|
+
...q.get("weigh") === "full" ? { max_files: 2e3 } : {},
|
|
3582
|
+
extra,
|
|
3583
|
+
contents_of: (u) => {
|
|
3584
|
+
try {
|
|
3585
|
+
const path = new URL(u).pathname;
|
|
3586
|
+
const rel = app_asset_rel(path);
|
|
3587
|
+
return chunkContents(rel ? "/" + rel : path);
|
|
3588
|
+
} catch {
|
|
3589
|
+
return null;
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3592
|
+
});
|
|
3593
|
+
} catch {
|
|
3594
|
+
s.assets_missing = "the page could not be weighed";
|
|
3595
|
+
}
|
|
3596
|
+
try {
|
|
3597
|
+
s.river = await this.#river_for(s, last_body, run_windows[run_windows.length - 1]);
|
|
3598
|
+
} catch {}
|
|
3599
|
+
try {
|
|
3600
|
+
s.lineage = await this.#lineage_for(s, last_body);
|
|
3601
|
+
} catch {}
|
|
3602
|
+
}
|
|
3603
|
+
try {
|
|
3604
|
+
await this.#lines_for(s, counted.profile, cap.window?.start, stable, wait_runs, almost);
|
|
3605
|
+
} catch {}
|
|
3606
|
+
const persist_wait = Math.max(200, Math.min(PERSIST_WAIT_MS, request_deadline - Date.now() - 2e3));
|
|
3607
|
+
await Promise.race([this.#persist(s), new Promise((r) => setTimeout(r, persist_wait))]);
|
|
3608
|
+
if (q.get("format") === "keep") return ctx.json({
|
|
3609
|
+
id,
|
|
3610
|
+
url: ctx.href("/report/[id]", { id }),
|
|
3611
|
+
dump: report_dump(s.analysis, s.meta, this.#report_extras(s))
|
|
3612
|
+
});
|
|
3613
|
+
if (q.get("format") === "ogp") return this.#ogp_response(id, s);
|
|
3614
|
+
if (q.get("format") === "json" || (ctx.request.headers.get("accept") ?? "").includes("application/json")) {
|
|
3615
|
+
const since = await this.#since_for(s, this.#prev_in_memory(s));
|
|
3616
|
+
const shared = this.#shared_of(s.meta);
|
|
3617
|
+
const full = {
|
|
3618
|
+
...report_json(s.analysis, s.meta, this.base, this.#report_extras(s)),
|
|
3619
|
+
...since ? { since } : {},
|
|
3620
|
+
...shared.length ? { shared } : {}
|
|
3621
|
+
};
|
|
3622
|
+
return ctx.json(pick_fields(full, q.get("fields")));
|
|
3623
|
+
}
|
|
3624
|
+
return ctx.redirect(ctx.href("/report/[id]", { id }));
|
|
3625
|
+
} catch (e) {
|
|
3626
|
+
if (isHttpError(e) || isRedirect(e)) throw e;
|
|
3627
|
+
return error(500, e instanceof Error && INSPECTOR_ERR.test(e.message) ? "CPU profiling needs a Node.js server (adapter-node or dev). This platform does not expose the V8 inspector." : `Profiling failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
3628
|
+
} finally {
|
|
3629
|
+
clearTimeout(watchdog);
|
|
3630
|
+
this.#recording_since = 0;
|
|
3631
|
+
this.#kept?.keep(false);
|
|
3632
|
+
this.#kept?.drop();
|
|
3633
|
+
this.#answers = null;
|
|
3634
|
+
this.#release_als();
|
|
3635
|
+
this.#release_recorder();
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
async #upload(ctx) {
|
|
3639
|
+
try {
|
|
3640
|
+
const bytes = recover_ogp_bytes(new Uint8Array(await ctx.request.arrayBuffer()));
|
|
3641
|
+
if (!is_ogp(bytes)) return ctx.json({ error: "That file is not an ogygia .ogp profile. If you moved it between machines, copy it as a binary file — a text editor or chat can silently re-encode it." }, { status: 400 });
|
|
3642
|
+
const ogp_key = ctx.request.headers.get("x-ogp-key") || this.secret;
|
|
3643
|
+
const dump = await ogp_decode(bytes, ogp_key);
|
|
3644
|
+
if (!is_dump(dump)) return ctx.json({ error: "That file is not an ogygia profiler dump." }, { status: 400 });
|
|
3645
|
+
return ctx.json({ url: ctx.href("/report/[id]", { id: this.#store_uploaded(dump) }) });
|
|
3646
|
+
} catch {
|
|
3647
|
+
return ctx.json({ error: "That file isn't a profile, or it was made with a different key." }, { status: 400 });
|
|
3648
|
+
}
|
|
3649
|
+
}
|
|
3650
|
+
/** the lines two or more profiled pages share, over the latest report of each page this server holds */
|
|
3651
|
+
#site_fixes(limit = 8) {
|
|
3652
|
+
return site_fixes([...this.#reports.values()].filter((r) => r.meta.trigger === "page" && r.meta.page && (r.meta.run_status ?? 200) === 200).map((r) => ({
|
|
3653
|
+
id: r.meta.id,
|
|
3654
|
+
page: r.meta.page,
|
|
3655
|
+
created: r.meta.created,
|
|
3656
|
+
runs: r.meta.runs?.length || 1,
|
|
3657
|
+
patterns: r.patterns
|
|
3658
|
+
})), limit);
|
|
3659
|
+
}
|
|
3660
|
+
/** of those, the ones this report's page is among: its fixes that make other pages faster too */
|
|
3661
|
+
#shared_of(meta) {
|
|
3662
|
+
if (meta.trigger !== "page" || !meta.page) return [];
|
|
3663
|
+
return this.#site_fixes(Infinity).filter((f) => f.pages.some((p) => p.page === meta.page)).slice(0, 5);
|
|
3664
|
+
}
|
|
3665
|
+
async #report_json(stored) {
|
|
3666
|
+
const since = await this.#since_for(stored, this.#prev_in_memory(stored));
|
|
3667
|
+
const shared = this.#shared_of(stored.meta);
|
|
3668
|
+
const body = {
|
|
3669
|
+
...report_json(stored.analysis, stored.meta, this.base, this.#report_extras(stored)),
|
|
3670
|
+
...since ? { since } : {},
|
|
3671
|
+
...shared.length ? { shared } : {}
|
|
3672
|
+
};
|
|
3673
|
+
return new Response(JSON.stringify(body), { headers: {
|
|
3674
|
+
"content-type": "application/json",
|
|
3675
|
+
"cache-control": "no-store"
|
|
3676
|
+
} });
|
|
3677
|
+
}
|
|
3678
|
+
/** The RAW dump JSON (`{ kind, version, meta, analysis, extras }`) the share-link viewer renders —
|
|
3679
|
+
* the same shape `.ogp` carries. Authed (only the owner mints a link); the recipient never hits it. */
|
|
3680
|
+
#report_dump_json(stored) {
|
|
3681
|
+
const body = report_dump(stored.analysis, stored.meta, this.#report_extras(stored));
|
|
3682
|
+
return new Response(JSON.stringify(body), { headers: {
|
|
3683
|
+
"content-type": "application/json",
|
|
3684
|
+
"cache-control": "no-store"
|
|
3685
|
+
} });
|
|
3686
|
+
}
|
|
3687
|
+
async #report_raw(stored) {
|
|
3688
|
+
let body;
|
|
3689
|
+
if (typeof stored.raw === "string") body = stored.raw;
|
|
3690
|
+
else try {
|
|
3691
|
+
const { gunzipSync } = await import("node:zlib");
|
|
3692
|
+
body = gunzipSync(stored.raw).toString("utf8");
|
|
3693
|
+
} catch {
|
|
3694
|
+
return new Response(new Uint8Array(stored.raw), { headers: {
|
|
3695
|
+
"content-type": "application/json",
|
|
3696
|
+
"content-encoding": "gzip",
|
|
3697
|
+
"content-disposition": `attachment; filename="ssr-${stored.meta.id}.cpuprofile"`,
|
|
3698
|
+
"cache-control": "no-store"
|
|
3699
|
+
} });
|
|
3700
|
+
}
|
|
3701
|
+
return new Response(body, { headers: {
|
|
3702
|
+
"content-type": "application/json",
|
|
3703
|
+
"content-disposition": `attachment; filename="ssr-${stored.meta.id}.cpuprofile"`,
|
|
3704
|
+
"cache-control": "no-store"
|
|
3705
|
+
} });
|
|
3706
|
+
}
|
|
3707
|
+
/**
|
|
3708
|
+
* Render a report page with its dump embedded as an encrypted `.ogp` (base64), so the Export button
|
|
3709
|
+
* downloads it with no server round-trip (the report may be evicted by then) and no key in the page.
|
|
3710
|
+
* Encoding is async (Brotli + scrypt off-thread — never blocks the event loop) and best-effort: if
|
|
3711
|
+
* Node crypto/zlib is unavailable (a stripped edge runtime), the Export button is simply omitted and
|
|
3712
|
+
* the JSON / .cpuprofile links still work.
|
|
3713
|
+
*/
|
|
3714
|
+
async #report_view(stored) {
|
|
3715
|
+
const { analysis: a, meta } = stored;
|
|
3716
|
+
const extras = this.#report_extras(stored);
|
|
3717
|
+
let ogpB64;
|
|
3718
|
+
try {
|
|
3719
|
+
const bytes = await ogp_encode(report_dump(a, meta, extras), this.secret);
|
|
3720
|
+
ogpB64 = Buffer.from(bytes).toString("base64");
|
|
3721
|
+
} catch {
|
|
3722
|
+
ogpB64 = void 0;
|
|
3723
|
+
}
|
|
3724
|
+
const history = meta.trigger === "page" && meta.page ? page_history([...this.#reports.values()].map((r) => r.meta)).find((h) => h.page === meta.page) ?? null : null;
|
|
3725
|
+
const i = history ? history.points.findIndex((p) => p.id === meta.id) : -1;
|
|
3726
|
+
const since = await this.#since_for(stored, i > 0 ? history.points[i - 1].id : null);
|
|
3727
|
+
return {
|
|
3728
|
+
a,
|
|
3729
|
+
meta,
|
|
3730
|
+
base: this.base,
|
|
3731
|
+
extras,
|
|
3732
|
+
ogpB64,
|
|
3733
|
+
history,
|
|
3734
|
+
prev: since?.prev ?? (i > 0 ? history.points[i - 1].id : null),
|
|
3735
|
+
since,
|
|
3736
|
+
dev: this.dev
|
|
3737
|
+
};
|
|
3738
|
+
}
|
|
3739
|
+
/**
|
|
3740
|
+
* SINCE THE LAST PROFILE of this page: the comparison, cut to what a reader needs at the top — the
|
|
3741
|
+
* render's change, the rows that really moved (inlining shifts left out), the order trap, the fix
|
|
3742
|
+
* check. The previous report from this instance's memory, else (it restarted; a serverless
|
|
3743
|
+
* instance is new) the configured store's newest earlier report of the same page.
|
|
3744
|
+
*/
|
|
3745
|
+
async #since_for(stored, prev_in_memory) {
|
|
3746
|
+
const { meta } = stored;
|
|
3747
|
+
let prev = prev_in_memory;
|
|
3748
|
+
if (!prev && meta.trigger === "page" && meta.page) try {
|
|
3749
|
+
const earlier = (await this.#list_stored(50)).filter((r) => r.page === meta.page && r.id !== meta.id && r.created < meta.created).sort((a, b) => b.created - a.created)[0];
|
|
3750
|
+
if (earlier) prev = earlier.id;
|
|
3751
|
+
} catch {}
|
|
3752
|
+
const P = prev ? this.#reports.get(prev) ?? await this.#report_load(prev) : void 0;
|
|
3753
|
+
if (!P) return null;
|
|
3754
|
+
try {
|
|
3755
|
+
const cmp = compare_reports(this.#pack(P), this.#pack(stored));
|
|
3756
|
+
const render = cmp.summary.find((r) => r.label.startsWith("render"));
|
|
3757
|
+
let score;
|
|
3758
|
+
try {
|
|
3759
|
+
const sa = page_score_of(P.meta, this.#report_extras(P));
|
|
3760
|
+
const sb = page_score_of(stored.meta, this.#report_extras(stored));
|
|
3761
|
+
const moved = sb.categories.map((c) => ({
|
|
3762
|
+
key: c.key,
|
|
3763
|
+
label: c.label,
|
|
3764
|
+
b: c.score,
|
|
3765
|
+
a: sa.categories.find((x) => x.key === c.key)?.score ?? NaN
|
|
3766
|
+
})).filter((m) => Number.isFinite(m.a) && Math.abs(m.b - m.a) >= 3).sort((x, y) => Math.abs(y.b - y.a) - Math.abs(x.b - x.a)).slice(0, 3);
|
|
3767
|
+
score = {
|
|
3768
|
+
a: sa.score,
|
|
3769
|
+
b: sb.score,
|
|
3770
|
+
a_grade: sa.grade,
|
|
3771
|
+
b_grade: sb.grade,
|
|
3772
|
+
moved
|
|
3773
|
+
};
|
|
3774
|
+
} catch {
|
|
3775
|
+
score = void 0;
|
|
3776
|
+
}
|
|
3777
|
+
let assets;
|
|
3778
|
+
try {
|
|
3779
|
+
if (P.assets && stored.assets) {
|
|
3780
|
+
const d = assets_diff(P.assets, stored.assets);
|
|
3781
|
+
if (d.added.length || d.removed.length || d.grew.length || d.shrank.length || d.to_start.length || d.to_later.length) assets = d;
|
|
3782
|
+
}
|
|
3783
|
+
} catch {
|
|
3784
|
+
assets = void 0;
|
|
3785
|
+
}
|
|
3786
|
+
let browser;
|
|
3787
|
+
try {
|
|
3788
|
+
const bf = (s) => {
|
|
3789
|
+
const e = this.#report_extras(s);
|
|
3790
|
+
if (!e.visit) return null;
|
|
3791
|
+
const names = new Map(island_rows_of(s.meta).map((r) => [r.fp, r.name]));
|
|
3792
|
+
return derive_findings(s.analysis, s.meta, e).filter((f) => f.fps).map((f) => `${f.code}${f.fps.length ? ` (${[...new Set(f.fps.map((fp) => names.get(fp) ?? fp.slice(0, 6)))].sort().join(", ")})` : ""}`);
|
|
3793
|
+
};
|
|
3794
|
+
const was = bf(P);
|
|
3795
|
+
const now = bf(stored);
|
|
3796
|
+
if (was && now) {
|
|
3797
|
+
const fixed = was.filter((x) => !now.includes(x));
|
|
3798
|
+
const added = now.filter((x) => !was.includes(x));
|
|
3799
|
+
if (fixed.length || added.length) browser = {
|
|
3800
|
+
fixed,
|
|
3801
|
+
added
|
|
3802
|
+
};
|
|
3803
|
+
}
|
|
3804
|
+
} catch {
|
|
3805
|
+
browser = void 0;
|
|
3806
|
+
}
|
|
3807
|
+
let vitals;
|
|
3808
|
+
try {
|
|
3809
|
+
const va = this.#report_extras(P).visit;
|
|
3810
|
+
const vb = this.#report_extras(stored).visit;
|
|
3811
|
+
if (va && vb) {
|
|
3812
|
+
const input = (v) => ({
|
|
3813
|
+
vitals: v.vitals ?? {},
|
|
3814
|
+
visit: {
|
|
3815
|
+
nav: v.nav,
|
|
3816
|
+
paints: v.paints,
|
|
3817
|
+
resources: v.resources,
|
|
3818
|
+
interaction: v.interaction,
|
|
3819
|
+
font_faces: v.font_faces
|
|
3820
|
+
},
|
|
3821
|
+
islands: [],
|
|
3822
|
+
firsts: [],
|
|
3823
|
+
shifts: [],
|
|
3824
|
+
longtasks: []
|
|
3825
|
+
});
|
|
3826
|
+
const moved = vitals_moved$1(va.vitals ?? {}, vb.vitals ?? {}, (side, key) => vital_parts(input(side === "a" ? va : vb), key));
|
|
3827
|
+
if (moved.length) vitals = moved;
|
|
3828
|
+
}
|
|
3829
|
+
} catch {
|
|
3830
|
+
vitals = void 0;
|
|
3831
|
+
}
|
|
3832
|
+
let islands;
|
|
3833
|
+
try {
|
|
3834
|
+
islands = island_files_diff(island_rows_of(P.meta), island_rows_of(stored.meta), (u) => stored.weights?.[u]) ?? void 0;
|
|
3835
|
+
} catch {
|
|
3836
|
+
islands = void 0;
|
|
3837
|
+
}
|
|
3838
|
+
return {
|
|
3839
|
+
prev: P.meta.id,
|
|
3840
|
+
...score ? { score } : {},
|
|
3841
|
+
...browser ? { browser } : {},
|
|
3842
|
+
...vitals ? { vitals } : {},
|
|
3843
|
+
...islands ? { islands } : {},
|
|
3844
|
+
...assets ? { assets } : {},
|
|
3845
|
+
...render ? {
|
|
3846
|
+
a_ms: render.a,
|
|
3847
|
+
b_ms: render.b
|
|
3848
|
+
} : {},
|
|
3849
|
+
...cmp.render_same ? { same: true } : {},
|
|
3850
|
+
moved: (cmp.drill ?? []).filter((r) => r.status !== "shifted").slice(0, 3),
|
|
3851
|
+
...cmp.order ? { order: cmp.order } : {},
|
|
3852
|
+
...cmp.fix_check ? { fix_check: cmp.fix_check } : {}
|
|
3853
|
+
};
|
|
3854
|
+
} catch {
|
|
3855
|
+
return null;
|
|
3856
|
+
}
|
|
3857
|
+
}
|
|
3858
|
+
/** this page's report just before `stored`, in this instance's memory */
|
|
3859
|
+
#prev_in_memory(stored) {
|
|
3860
|
+
const { meta } = stored;
|
|
3861
|
+
if (meta.trigger !== "page" || !meta.page) return null;
|
|
3862
|
+
const h = page_history([...this.#reports.values()].map((r) => r.meta)).find((x) => x.page === meta.page);
|
|
3863
|
+
const i = h ? h.points.findIndex((p) => p.id === meta.id) : -1;
|
|
3864
|
+
return i > 0 ? h.points[i - 1].id : null;
|
|
3865
|
+
}
|
|
3866
|
+
/** Store an uploaded dump as a report so it renders (+ hydrates) at its own /report/<id> URL — an
|
|
3867
|
+
* islands page can't be swapped in via document.write. No cpuprofile in a dump, so `raw` is empty
|
|
3868
|
+
* (its /raw link just 404s; everything else works). */
|
|
3869
|
+
#store_uploaded(dump) {
|
|
3870
|
+
const id = Math.random().toString(36).slice(2, 10);
|
|
3871
|
+
this.#reports.set(id, this.#report_from_dump({
|
|
3872
|
+
...dump,
|
|
3873
|
+
meta: {
|
|
3874
|
+
...dump.meta,
|
|
3875
|
+
id
|
|
3876
|
+
}
|
|
3877
|
+
}));
|
|
3878
|
+
while (this.#reports.size > this.max_reports) {
|
|
3879
|
+
const oldest = this.#reports.keys().next().value;
|
|
3880
|
+
if (oldest === void 0) break;
|
|
3881
|
+
this.#reports.delete(oldest);
|
|
3882
|
+
}
|
|
3883
|
+
return id;
|
|
3884
|
+
}
|
|
3885
|
+
/** A StoredReport rebuilt from a portable dump (an uploaded `.ogp`, or one read back from the
|
|
3886
|
+
* storage backend). No cpuprofile survives a dump, so `raw` is empty (the `/raw` link 404s). */
|
|
3887
|
+
#report_from_dump(dump) {
|
|
3888
|
+
const e = dump.extras;
|
|
3889
|
+
return {
|
|
3890
|
+
meta: dump.meta,
|
|
3891
|
+
analysis: dump.analysis,
|
|
3892
|
+
heap: e.heap,
|
|
3893
|
+
...e.heap_components ? { heap_components: e.heap_components } : {},
|
|
3894
|
+
net: e.net,
|
|
3895
|
+
mem: e.mem,
|
|
3896
|
+
measures: e.measures ?? [],
|
|
3897
|
+
gc: e.gc ?? null,
|
|
3898
|
+
io: e.io ?? [],
|
|
3899
|
+
call_counts: e.call_counts ?? {},
|
|
3900
|
+
raw: "",
|
|
3901
|
+
...e.weights ? { weights: e.weights } : {},
|
|
3902
|
+
...e.contents ? { contents: e.contents } : {},
|
|
3903
|
+
...e.heavy ? { heavy: e.heavy } : {},
|
|
3904
|
+
...e.barrels ? { barrels: e.barrels } : {},
|
|
3905
|
+
...e.hazards ? { hazards: e.hazards } : {},
|
|
3906
|
+
...e.client ? { client: e.client } : {},
|
|
3907
|
+
...e.vitals ? { vitals: e.vitals } : {},
|
|
3908
|
+
...e.client_marks ? { client_marks: e.client_marks } : {},
|
|
3909
|
+
...e.client_cpu ? { client_cpu: e.client_cpu } : {},
|
|
3910
|
+
...e.interaction_cpu ? { interaction_cpu: e.interaction_cpu } : {},
|
|
3911
|
+
...e.alloc ? { alloc: e.alloc } : {},
|
|
3912
|
+
...e.contention ? { contention: e.contention } : {},
|
|
3913
|
+
...e.lineage ? { lineage: e.lineage } : {},
|
|
3914
|
+
...e.spans ? { spans: e.spans } : {},
|
|
3915
|
+
...e.visit ? { visit: e.visit } : {},
|
|
3916
|
+
...e.strip ? { strip: e.strip } : {},
|
|
3917
|
+
...e.assets ? { assets: e.assets } : {},
|
|
3918
|
+
...e.assets_missing ? { assets_missing: e.assets_missing } : {},
|
|
3919
|
+
...e.river ? { river: e.river } : {},
|
|
3920
|
+
...e.gc_attr ? { gc_attr: e.gc_attr } : {},
|
|
3921
|
+
...e.promises ? { promises: e.promises } : {},
|
|
3922
|
+
...e.retained ? { retained: e.retained } : {},
|
|
3923
|
+
...e.ledger ? { ledger: e.ledger } : {},
|
|
3924
|
+
...e.patterns ? { patterns: e.patterns } : {},
|
|
3925
|
+
...e.drill ? { drill: e.drill } : {},
|
|
3926
|
+
...e.forecast ? { forecast: e.forecast } : {},
|
|
3927
|
+
...e.growth ? { growth: e.growth } : {}
|
|
3928
|
+
};
|
|
3929
|
+
}
|
|
3930
|
+
/** Write a finished report to the durable store, when one is configured (a store failure never
|
|
3931
|
+
* fails the recording). The dump is the same portable JSON the `.ogp` export uses. The returned
|
|
3932
|
+
* promise settles when the write did: a serverless host freezes the process once the response
|
|
3933
|
+
* is sent, so a write left running after it may never land — the page recording awaits it. */
|
|
3934
|
+
#persist(stored) {
|
|
3935
|
+
if (!this.#store) return Promise.resolve();
|
|
3936
|
+
const meta = stored.meta;
|
|
3937
|
+
const median = meta.runs?.length ? [...meta.runs].sort((a, b) => a - b)[Math.floor(meta.runs.length / 2)] : null;
|
|
3938
|
+
const rec = {
|
|
3939
|
+
id: meta.id,
|
|
3940
|
+
created: meta.created,
|
|
3941
|
+
page: meta.page ?? null,
|
|
3942
|
+
trigger: meta.trigger,
|
|
3943
|
+
label: meta.page ?? meta.request?.path ?? meta.id,
|
|
3944
|
+
median,
|
|
3945
|
+
dump: JSON.stringify(report_dump(stored.analysis, meta, this.#report_extras(stored)))
|
|
3946
|
+
};
|
|
3947
|
+
return Promise.resolve().then(async () => {
|
|
3948
|
+
await this.#store.putReport(rec);
|
|
3949
|
+
await this.#store.prune?.(Math.max(this.max_reports * 8, 200));
|
|
3950
|
+
}).catch((err) => {
|
|
3951
|
+
if (process.env.OGYGIA_PROFILER_DEBUG) console.error("[ogygia/profiler] store.putReport failed:", err);
|
|
3952
|
+
});
|
|
3953
|
+
}
|
|
3954
|
+
/** Memory first, then the durable store: read the dump back, reconstruct, cache in memory. */
|
|
3955
|
+
async #report_load(id) {
|
|
3956
|
+
if (!id) return void 0;
|
|
3957
|
+
const hit = this.#reports.get(id);
|
|
3958
|
+
if (hit) return hit;
|
|
3959
|
+
if (!this.#store) return void 0;
|
|
3960
|
+
try {
|
|
3961
|
+
const json = await this.#store.getReport(id);
|
|
3962
|
+
if (!json) return void 0;
|
|
3963
|
+
const dump = JSON.parse(json);
|
|
3964
|
+
if (!dump?.meta || !dump.analysis) return void 0;
|
|
3965
|
+
const stored = this.#report_from_dump({
|
|
3966
|
+
...dump,
|
|
3967
|
+
meta: {
|
|
3968
|
+
...dump.meta,
|
|
3969
|
+
id
|
|
3970
|
+
}
|
|
3971
|
+
});
|
|
3972
|
+
this.#reports.set(id, stored);
|
|
3973
|
+
return stored;
|
|
3974
|
+
} catch (err) {
|
|
3975
|
+
if (process.env.OGYGIA_PROFILER_DEBUG) console.error("[ogygia/profiler] store.getReport failed:", err);
|
|
3976
|
+
return;
|
|
3977
|
+
}
|
|
3978
|
+
}
|
|
3979
|
+
/** The reports the durable store holds (summaries), newest first — the sidebar's shared list. */
|
|
3980
|
+
async #list_stored(limit = 20) {
|
|
3981
|
+
if (!this.#store) return [];
|
|
3982
|
+
try {
|
|
3983
|
+
return (await this.#store.listReports(limit)).map((r) => ({
|
|
3984
|
+
id: r.id,
|
|
3985
|
+
label: r.label,
|
|
3986
|
+
page: r.page ?? void 0,
|
|
3987
|
+
created: r.created
|
|
3988
|
+
}));
|
|
3989
|
+
} catch {
|
|
3990
|
+
return [];
|
|
3991
|
+
}
|
|
3992
|
+
}
|
|
3993
|
+
/** The full profile as an encrypted `.ogp` download (Brotli + AES-GCM, async — never blocks). */
|
|
3994
|
+
async #ogp_response(id, stored) {
|
|
3995
|
+
const bytes = await ogp_encode(report_dump(stored.analysis, stored.meta, this.#report_extras(stored)), this.secret);
|
|
3996
|
+
return new Response(new Uint8Array(bytes), { headers: {
|
|
3997
|
+
"content-type": "application/octet-stream",
|
|
3998
|
+
"content-disposition": `attachment; filename="profile-${id}.ogp"`,
|
|
3999
|
+
"cache-control": "no-store"
|
|
4000
|
+
} });
|
|
4001
|
+
}
|
|
4002
|
+
#report_extras(stored) {
|
|
4003
|
+
const client = stored.client ?? this.#client_for(stored);
|
|
4004
|
+
const browser_page = stored.meta.page?.split("?")[0];
|
|
4005
|
+
const vitals = stored.vitals ?? this.#vitals_for(browser_page);
|
|
4006
|
+
const client_marks = stored.client_marks ?? this.#marks_for(browser_page);
|
|
4007
|
+
const client_cpu = stored.client_cpu ?? (browser_page ? this.#client_cpus.get(browser_page) : void 0);
|
|
4008
|
+
const own_visit = () => {
|
|
4009
|
+
const created = stored.meta.created - 1e3;
|
|
4010
|
+
if (stored.visit && stored.visit.at >= created) return stored.visit;
|
|
4011
|
+
const list = browser_page ? this.#visits.get(browser_page) ?? [] : [];
|
|
4012
|
+
let first;
|
|
4013
|
+
for (const v of list) if (v.at >= created && (!first || v.at < first.at)) first = v;
|
|
4014
|
+
return first ?? stored.visit ?? list.at(-1);
|
|
4015
|
+
};
|
|
4016
|
+
const visit = own_visit();
|
|
4017
|
+
const answered = new Set((visit?.holes_answered ?? []).map((h) => h.id));
|
|
4018
|
+
const hole_requests = answered.size ? this.#ring.filter((e) => e.hole && answered.has(e.hole.id) && e.ts >= visit.at - 1e3 && e.ts <= visit.at + 6e4).map((e) => ({
|
|
4019
|
+
id: e.hole.id,
|
|
4020
|
+
ms: Math.max(0, e.ms - (e.hole.queue_ms ?? 0)),
|
|
4021
|
+
...e.hole.name ? { name: e.hole.name } : {},
|
|
4022
|
+
...e.hole.p ? { p: e.hole.p } : {}
|
|
4023
|
+
})) : [];
|
|
4024
|
+
const nav_requests = (visit?.navs ?? []).map((n) => {
|
|
4025
|
+
const lo = visit.at + n.t - 1e4;
|
|
4026
|
+
const hi = visit.at + n.fetched + 50;
|
|
4027
|
+
let hit;
|
|
4028
|
+
for (const e of this.#ring) {
|
|
4029
|
+
if (e.internal || e.method !== "GET" || e.path + (e.search ?? "") !== n.to || e.ts < lo || e.ts > hi) continue;
|
|
4030
|
+
if (!hit || e.ts > hit.ts) hit = e;
|
|
4031
|
+
}
|
|
4032
|
+
return hit ? {
|
|
4033
|
+
t: n.t,
|
|
4034
|
+
ms: Math.round(hit.ms),
|
|
4035
|
+
cpu_ms: Math.round(hit.cpu_ms),
|
|
4036
|
+
net_ms: Math.round(hit.net_ms),
|
|
4037
|
+
net_count: hit.net_count,
|
|
4038
|
+
...hit.inflight ? { inflight: hit.inflight } : {}
|
|
4039
|
+
} : null;
|
|
4040
|
+
}).filter((x) => !!x);
|
|
4041
|
+
let doc_request;
|
|
4042
|
+
if (visit?.nav && stored.meta.page) {
|
|
4043
|
+
const path = stored.meta.page.split("?")[0];
|
|
4044
|
+
for (const e of this.#ring) {
|
|
4045
|
+
if (e.internal || e.method !== "GET" || e.path !== path) continue;
|
|
4046
|
+
if (e.ts < visit.at - 500 || e.ts > visit.at + visit.nav.res_start + 500) continue;
|
|
4047
|
+
doc_request = { ms: Math.round(e.ms) };
|
|
4048
|
+
}
|
|
4049
|
+
}
|
|
4050
|
+
const icpu = stored.interaction_cpu ?? (browser_page ? this.#interaction_cpus.get(browser_page) : void 0);
|
|
4051
|
+
const interaction_cpu = icpu && visit?.interaction && Math.abs(icpu.t - visit.interaction.t) < 2 ? icpu : void 0;
|
|
4052
|
+
const narrow = (v) => (v.viewport?.[0] ?? 0) < NARROW_SCREEN_PX;
|
|
4053
|
+
const other_screen_visit = visit?.viewport && browser_page ? (this.#visits.get(browser_page) ?? []).filter((v) => v !== visit && v.viewport && narrow(v) !== narrow(visit)).at(-1) : void 0;
|
|
4054
|
+
const engine = engine_of(visit?.ua);
|
|
4055
|
+
const other_engine_visit = engine && browser_page ? (this.#visits.get(browser_page) ?? []).filter((v) => v !== visit && engine_of(v.ua) && engine_of(v.ua) !== engine).at(-1) : void 0;
|
|
4056
|
+
const dupes = chunkDuplicates();
|
|
4057
|
+
return {
|
|
4058
|
+
...visit ? { visit } : {},
|
|
4059
|
+
...other_screen_visit ? { other_screen_visit } : {},
|
|
4060
|
+
...other_engine_visit ? { other_engine_visit } : {},
|
|
4061
|
+
...dupes ? { dupes } : {},
|
|
4062
|
+
...interaction_cpu ? { interaction_cpu } : {},
|
|
4063
|
+
...hole_requests.length ? { hole_requests } : {},
|
|
4064
|
+
...nav_requests.length ? { nav_requests } : {},
|
|
4065
|
+
...doc_request ? { doc_request } : {},
|
|
4066
|
+
...stored.strip ? { strip: stored.strip } : {},
|
|
4067
|
+
...stored.assets ? { assets: stored.assets } : {},
|
|
4068
|
+
...stored.assets_missing ? { assets_missing: stored.assets_missing } : {},
|
|
4069
|
+
...stored.river ? { river: stored.river } : {},
|
|
4070
|
+
...stored.gc_attr ? { gc_attr: stored.gc_attr } : {},
|
|
4071
|
+
...stored.promises ? { promises: stored.promises } : {},
|
|
4072
|
+
...stored.retained ? { retained: stored.retained } : {},
|
|
4073
|
+
...stored.alloc ? { alloc: stored.alloc } : {},
|
|
4074
|
+
...stored.contention ? { contention: stored.contention } : {},
|
|
4075
|
+
...stored.lineage ? { lineage: stored.lineage } : {},
|
|
4076
|
+
...stored.ledger ? { ledger: stored.ledger } : {},
|
|
4077
|
+
...stored.patterns ? { patterns: stored.patterns } : {},
|
|
4078
|
+
...stored.drill ? { drill: stored.drill } : {},
|
|
4079
|
+
...stored.forecast ? { forecast: stored.forecast } : {},
|
|
4080
|
+
...stored.growth ? { growth: stored.growth } : {},
|
|
4081
|
+
net: stored.net,
|
|
4082
|
+
spans: stored.spans ?? [],
|
|
4083
|
+
heap: stored.heap,
|
|
4084
|
+
...stored.heap_components ? { heap_components: stored.heap_components } : {},
|
|
4085
|
+
mem: stored.mem,
|
|
4086
|
+
measures: stored.measures,
|
|
4087
|
+
gc: stored.gc,
|
|
4088
|
+
io: stored.io,
|
|
4089
|
+
call_counts: stored.call_counts,
|
|
4090
|
+
...stored.weights ? { weights: stored.weights } : {},
|
|
4091
|
+
...stored.contents ? { contents: stored.contents } : {},
|
|
4092
|
+
...stored.heavy ? { heavy: stored.heavy } : {},
|
|
4093
|
+
...stored.barrels ? { barrels: stored.barrels } : {},
|
|
4094
|
+
...stored.hazards ? { hazards: stored.hazards } : {},
|
|
4095
|
+
...client.length ? { client } : {},
|
|
4096
|
+
...vitals ? { vitals } : {},
|
|
4097
|
+
...client_marks?.length ? { client_marks } : {},
|
|
4098
|
+
...client_cpu ? { client_cpu } : {},
|
|
4099
|
+
...stored.replay ? { replay: stored.replay } : {}
|
|
4100
|
+
};
|
|
4101
|
+
}
|
|
4102
|
+
/** The app's own browser marks for the page, per name (p50 / max over the visits). */
|
|
4103
|
+
#marks_for(page) {
|
|
4104
|
+
if (!page) return void 0;
|
|
4105
|
+
const by_name = this.#marks.get(page);
|
|
4106
|
+
if (!by_name?.size) return void 0;
|
|
4107
|
+
const out = [];
|
|
4108
|
+
for (const [name, a] of by_name) {
|
|
4109
|
+
const ms = [...a.ms].sort((x, y) => x - y);
|
|
4110
|
+
out.push({
|
|
4111
|
+
name,
|
|
4112
|
+
n: ms.length,
|
|
4113
|
+
p50_ms: round2(percentile(ms, .5)),
|
|
4114
|
+
max_ms: round2(ms[ms.length - 1] ?? 0),
|
|
4115
|
+
errors: a.errors,
|
|
4116
|
+
attr_keys: [...a.attr_keys].sort()
|
|
4117
|
+
});
|
|
4118
|
+
}
|
|
4119
|
+
return out.sort((x, y) => y.p50_ms - x.p50_ms);
|
|
4120
|
+
}
|
|
4121
|
+
/** The page's web vitals as the profiler user's browser reported them (p50 over the visits). */
|
|
4122
|
+
#vitals_for(page) {
|
|
4123
|
+
if (!page) return void 0;
|
|
4124
|
+
const e = this.#vitals.get(page);
|
|
4125
|
+
if (!e || !e.samples.length) return void 0;
|
|
4126
|
+
const p50 = (k) => {
|
|
4127
|
+
const xs = e.samples.map((s) => s[k]).filter((x) => x !== void 0).sort((a, b) => a - b);
|
|
4128
|
+
return xs.length ? round2(percentile(xs, .5)) : null;
|
|
4129
|
+
};
|
|
4130
|
+
return {
|
|
4131
|
+
n: e.samples.length,
|
|
4132
|
+
ttfb: p50("ttfb"),
|
|
4133
|
+
fcp: p50("fcp"),
|
|
4134
|
+
lcp: p50("lcp"),
|
|
4135
|
+
cls: p50("cls"),
|
|
4136
|
+
inp: p50("inp")
|
|
4137
|
+
};
|
|
4138
|
+
}
|
|
4139
|
+
/** The browser's hydration timings for THIS report's islands: the beacon ring joined by
|
|
4140
|
+
* fingerprint (the same props → the same fingerprint, so a visit after the recording matches),
|
|
4141
|
+
* then merged per island — a list of 48 cards is 48 fingerprints and one row. */
|
|
4142
|
+
#client_for(stored) {
|
|
4143
|
+
const per_entry = /* @__PURE__ */ new Map();
|
|
4144
|
+
const by_entry = /* @__PURE__ */ new Map();
|
|
4145
|
+
for (const b of this.#beacons.values()) if (b.entry && b.ms.length) by_entry.set(entry_path(b.entry), b);
|
|
4146
|
+
for (const r of island_rows_of(stored.meta)) {
|
|
4147
|
+
const b = this.#beacons.get(r.fp) ?? by_entry.get(entry_path(r.entry));
|
|
4148
|
+
if (!b || !b.ms.length) continue;
|
|
4149
|
+
let e = per_entry.get(r.entry);
|
|
4150
|
+
if (!e) per_entry.set(r.entry, e = {
|
|
4151
|
+
fp: r.fp,
|
|
4152
|
+
name: r.name,
|
|
4153
|
+
ms: [],
|
|
4154
|
+
load: [],
|
|
4155
|
+
recovered: 0
|
|
4156
|
+
});
|
|
4157
|
+
e.ms.push(...b.ms);
|
|
4158
|
+
e.load.push(...b.load);
|
|
4159
|
+
e.recovered += b.recovered;
|
|
4160
|
+
if (b.reason) e.reason = b.reason;
|
|
4161
|
+
}
|
|
4162
|
+
const out = [];
|
|
4163
|
+
for (const [entry, e] of per_entry) {
|
|
4164
|
+
const ms = e.ms.sort((x, y) => x - y);
|
|
4165
|
+
const load = e.load.sort((x, y) => x - y);
|
|
4166
|
+
out.push({
|
|
4167
|
+
fp: e.fp,
|
|
4168
|
+
entry,
|
|
4169
|
+
name: e.name,
|
|
4170
|
+
n: ms.length,
|
|
4171
|
+
p50_ms: round2(percentile(ms, .5)),
|
|
4172
|
+
max_ms: round2(ms[ms.length - 1]),
|
|
4173
|
+
load_p50_ms: round2(percentile(load, .5)),
|
|
4174
|
+
recovered: e.recovered,
|
|
4175
|
+
...e.reason ? { reason: e.reason } : {}
|
|
4176
|
+
});
|
|
4177
|
+
}
|
|
4178
|
+
return out;
|
|
4179
|
+
}
|
|
4180
|
+
/** `/replay/[id]`: profile a caught (or header-profiled) request again, in full page mode, with
|
|
4181
|
+
* the inputs it was caught with — its path and query, and the headers the trap config kept. */
|
|
4182
|
+
async #replay(id, ctx) {
|
|
4183
|
+
const stored = this.#reports.get(id ?? "");
|
|
4184
|
+
if (!stored?.meta.request) return new Response("Nothing to replay: that report has no request.", { status: 404 });
|
|
4185
|
+
const replay = stored.replay ?? {
|
|
4186
|
+
path: stored.meta.request.path,
|
|
4187
|
+
headers: {}
|
|
4188
|
+
};
|
|
4189
|
+
return this.#record_page(ctx, replay);
|
|
4190
|
+
}
|
|
4191
|
+
/** The browser's CPU profile (Chromium's JS Self-Profiling trace) → the same analysis as the
|
|
4192
|
+
* server's: components, functions, flame. Categories come from the chunk contents the build
|
|
4193
|
+
* recorded, since a client chunk's URL says nothing about what it holds. */
|
|
4194
|
+
async #ingest_client_cpu(page, trace, origin, interaction) {
|
|
4195
|
+
const profile = self_profile_to_cpuprofile(trace);
|
|
4196
|
+
if (!profile) return false;
|
|
4197
|
+
const find = await this.#client_file_finder(origin);
|
|
4198
|
+
let rewritten = 0;
|
|
4199
|
+
if (find) for (const n of profile.nodes) {
|
|
4200
|
+
const file = find(n.callFrame.url);
|
|
4201
|
+
if (file) {
|
|
4202
|
+
n.callFrame.url = file;
|
|
4203
|
+
rewritten++;
|
|
4204
|
+
}
|
|
4205
|
+
}
|
|
4206
|
+
if (this.dev && origin) for (const n of profile.nodes) {
|
|
4207
|
+
const u = n.callFrame.url;
|
|
4208
|
+
if (u.startsWith(origin + "/")) {
|
|
4209
|
+
n.callFrame.url = u.slice(origin.length);
|
|
4210
|
+
rewritten++;
|
|
4211
|
+
}
|
|
4212
|
+
}
|
|
4213
|
+
const resolver = rewritten || !this.dev ? await this.#make_resolver() : void 0;
|
|
4214
|
+
const url_hint = (url, name) => {
|
|
4215
|
+
let path;
|
|
4216
|
+
try {
|
|
4217
|
+
const u = new URL(url, "http://x");
|
|
4218
|
+
path = u.pathname;
|
|
4219
|
+
if (origin && u.host !== "x" && u.origin !== origin) return {
|
|
4220
|
+
category: "dependency",
|
|
4221
|
+
pkg: CDN_PKG_RE.exec(path)?.[1] ?? u.host
|
|
4222
|
+
};
|
|
4223
|
+
} catch {
|
|
4224
|
+
return;
|
|
4225
|
+
}
|
|
4226
|
+
const rel = app_asset_rel(path);
|
|
4227
|
+
if (rel) path = "/" + rel;
|
|
4228
|
+
const inside = chunkContents(path);
|
|
4229
|
+
if (inside) {
|
|
4230
|
+
if (inside.every((s) => s === "svelte runtime")) return "svelte";
|
|
4231
|
+
if (!inside.some((s) => s.startsWith("src/") || s.endsWith(".svelte") || s.startsWith("+"))) {
|
|
4232
|
+
const pkgs = inside.map((s) => s.replace(/ runtime$/, "")).filter((s) => !s.startsWith("+"));
|
|
4233
|
+
return {
|
|
4234
|
+
category: "dependency",
|
|
4235
|
+
pkg: pkgs.length > 2 ? `${pkgs[0]} +${pkgs.length - 1}` : pkgs.join(" + ")
|
|
4236
|
+
};
|
|
4237
|
+
}
|
|
4238
|
+
return;
|
|
4239
|
+
}
|
|
4240
|
+
return SVELTE_CLIENT_FN_RE.test(name) ? "svelte" : void 0;
|
|
4241
|
+
};
|
|
4242
|
+
const served = (p) => {
|
|
4243
|
+
const rel = app_asset_rel(p);
|
|
4244
|
+
return rel ? "/" + rel : p;
|
|
4245
|
+
};
|
|
4246
|
+
const renamer = chunk_component_renamer((p) => chunkContents(served(p)));
|
|
4247
|
+
const run = (p) => analyze(p, resolver, void 0, void 0, renamer, url_hint);
|
|
4248
|
+
if (interaction) {
|
|
4249
|
+
if (this.#interaction_cpus.size >= 50 && !this.#interaction_cpus.has(page)) this.#interaction_cpus.delete(this.#interaction_cpus.keys().next().value);
|
|
4250
|
+
this.#interaction_cpus.set(page, {
|
|
4251
|
+
...interaction_windows(profile, interaction, run),
|
|
4252
|
+
at: Date.now()
|
|
4253
|
+
});
|
|
4254
|
+
return true;
|
|
4255
|
+
}
|
|
4256
|
+
const analysis = run(profile);
|
|
4257
|
+
const visit = this.#visits.get(page)?.at(-1);
|
|
4258
|
+
const windows = visit && Date.now() - visit.at < 12e4 ? client_windows(profile, visit, run) : void 0;
|
|
4259
|
+
const i = visit && Date.now() - visit.at < 12e4 ? visit.interaction : void 0;
|
|
4260
|
+
if (i && i.ms >= 200 && i.t * 1e3 >= profile.startTime && (i.t + i.ms) * 1e3 <= profile.endTime) {
|
|
4261
|
+
const at = i.t + i.delay;
|
|
4262
|
+
if (this.#interaction_cpus.size >= 50 && !this.#interaction_cpus.has(page)) this.#interaction_cpus.delete(this.#interaction_cpus.keys().next().value);
|
|
4263
|
+
this.#interaction_cpus.set(page, {
|
|
4264
|
+
...interaction_windows(profile, {
|
|
4265
|
+
t: i.t,
|
|
4266
|
+
wait: [i.t, at],
|
|
4267
|
+
handler: [at, at + i.processing]
|
|
4268
|
+
}, run),
|
|
4269
|
+
at: Date.now()
|
|
4270
|
+
});
|
|
4271
|
+
}
|
|
4272
|
+
if (this.#client_cpus.size >= 50 && !this.#client_cpus.has(page)) this.#client_cpus.delete(this.#client_cpus.keys().next().value);
|
|
4273
|
+
this.#client_cpus.set(page, {
|
|
4274
|
+
analysis,
|
|
4275
|
+
at: Date.now(),
|
|
4276
|
+
sample_ms: analysis.duration_ms,
|
|
4277
|
+
...windows ? { windows } : {}
|
|
4278
|
+
});
|
|
4279
|
+
return true;
|
|
4280
|
+
}
|
|
4281
|
+
/** `/beacon` (POST, authed): the runtime's hydration timings — `{ islands: [{ fp, entry, ms, load }] }`.
|
|
4282
|
+
* Bounded every way (body, islands per post, fingerprints kept, samples per fingerprint). */
|
|
4283
|
+
async #beacon(ctx) {
|
|
4284
|
+
let body;
|
|
4285
|
+
try {
|
|
4286
|
+
const text = await ctx.request.text();
|
|
4287
|
+
if (text.length > (text.includes("\"cpu\"") ? MAX_BEACON_CPU_BODY : text.includes("\"visit\"") ? MAX_BEACON_VISIT_BODY : MAX_BEACON_BODY)) return new Response(null, { status: 413 });
|
|
4288
|
+
body = JSON.parse(text);
|
|
4289
|
+
} catch {
|
|
4290
|
+
return new Response(null, { status: 400 });
|
|
4291
|
+
}
|
|
4292
|
+
const islands = body?.islands;
|
|
4293
|
+
const visit_body = body?.visit;
|
|
4294
|
+
const vitals = body?.vitals ?? (visit_body && typeof visit_body === "object" ? visit_body.vitals : void 0);
|
|
4295
|
+
const vitals_at_raw = Number(body?.at ?? (visit_body && typeof visit_body === "object" ? visit_body.at : void 0));
|
|
4296
|
+
const vitals_at = Number.isFinite(vitals_at_raw) && vitals_at_raw > 0 ? Math.round(vitals_at_raw) : void 0;
|
|
4297
|
+
const marks = body?.marks;
|
|
4298
|
+
const cpu = body?.cpu;
|
|
4299
|
+
const page = body?.page;
|
|
4300
|
+
const visit_raw = body?.visit;
|
|
4301
|
+
if (!Array.isArray(islands) && !(vitals && typeof vitals === "object") && !Array.isArray(marks) && !(cpu && typeof cpu === "object") && !(visit_raw && typeof visit_raw === "object")) return new Response(null, { status: 400 });
|
|
4302
|
+
if (visit_raw && typeof visit_raw === "object") {
|
|
4303
|
+
const visit = parse_visit(page, visit_raw);
|
|
4304
|
+
if (!visit) return new Response(null, { status: 400 });
|
|
4305
|
+
let list = this.#visits.get(visit.page);
|
|
4306
|
+
if (!list) {
|
|
4307
|
+
if (this.#visits.size >= MAX_VITALS_PATHS) this.#visits.delete(this.#visits.keys().next().value);
|
|
4308
|
+
this.#visits.set(visit.page, list = []);
|
|
4309
|
+
}
|
|
4310
|
+
const same = list.findIndex((v) => v.at === visit.at);
|
|
4311
|
+
if (same !== -1) list[same] = merge_visits(list[same], visit);
|
|
4312
|
+
else {
|
|
4313
|
+
list.push(visit);
|
|
4314
|
+
if (list.length > MAX_VISITS_PER_PAGE) list.shift();
|
|
4315
|
+
}
|
|
4316
|
+
}
|
|
4317
|
+
if (cpu && typeof cpu === "object" && typeof page === "string" && page.startsWith("/") && page.length <= 500 && this.#client_cpu) {
|
|
4318
|
+
const it = body?.interaction;
|
|
4319
|
+
const pair = (v) => Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0 && n < 36e5) && v[1] >= v[0] ? [v[0], v[1]] : null;
|
|
4320
|
+
const wait = pair(it?.wait);
|
|
4321
|
+
const handler = pair(it?.handler);
|
|
4322
|
+
const spans = it && typeof it.t === "number" && Number.isFinite(it.t) && wait && handler ? {
|
|
4323
|
+
t: it.t,
|
|
4324
|
+
wait,
|
|
4325
|
+
handler
|
|
4326
|
+
} : void 0;
|
|
4327
|
+
if (it && !spans) return new Response(null, { status: 400 });
|
|
4328
|
+
if (!await this.#ingest_client_cpu(page, cpu, ctx.url.origin, spans)) return new Response(null, { status: 400 });
|
|
4329
|
+
}
|
|
4330
|
+
const now = Date.now();
|
|
4331
|
+
if (Array.isArray(marks) && typeof page === "string" && page.startsWith("/") && page.length <= 500) {
|
|
4332
|
+
let by_name = this.#marks.get(page);
|
|
4333
|
+
if (!by_name) {
|
|
4334
|
+
if (this.#marks.size >= MAX_VITALS_PATHS) this.#marks.delete(this.#marks.keys().next().value);
|
|
4335
|
+
this.#marks.set(page, by_name = /* @__PURE__ */ new Map());
|
|
4336
|
+
}
|
|
4337
|
+
for (const m of marks.slice(0, MAX_BEACON_ISLANDS)) {
|
|
4338
|
+
const name = typeof m?.name === "string" ? m.name.slice(0, 120) : "";
|
|
4339
|
+
const ms = Number(m?.ms);
|
|
4340
|
+
if (!name || !Number.isFinite(ms) || ms < 0 || ms > 6e5) continue;
|
|
4341
|
+
let agg = by_name.get(name);
|
|
4342
|
+
if (!agg) {
|
|
4343
|
+
if (by_name.size >= 100) continue;
|
|
4344
|
+
by_name.set(name, agg = {
|
|
4345
|
+
ms: [],
|
|
4346
|
+
errors: 0,
|
|
4347
|
+
attr_keys: /* @__PURE__ */ new Set()
|
|
4348
|
+
});
|
|
4349
|
+
}
|
|
4350
|
+
agg.ms.push(round2(ms));
|
|
4351
|
+
if (agg.ms.length > MAX_BEACON_SAMPLES) agg.ms.shift();
|
|
4352
|
+
const attrs = m?.attrs && typeof m.attrs === "object" ? m.attrs : null;
|
|
4353
|
+
if (attrs?.error === true) agg.errors++;
|
|
4354
|
+
for (const k of Object.keys(attrs ?? {}).slice(0, 12)) if (k !== "error") agg.attr_keys.add(k.slice(0, 40));
|
|
4355
|
+
}
|
|
4356
|
+
}
|
|
4357
|
+
if (vitals && typeof vitals === "object" && typeof page === "string" && page.startsWith("/") && page.length <= 500) {
|
|
4358
|
+
const v = vitals;
|
|
4359
|
+
const num = (k, max) => {
|
|
4360
|
+
const n = Number(v[k]);
|
|
4361
|
+
return Number.isFinite(n) && n >= 0 && n <= max ? round2(n) : void 0;
|
|
4362
|
+
};
|
|
4363
|
+
const sample = vitals_at !== void 0 ? { at: vitals_at } : {};
|
|
4364
|
+
const ttfb = num("ttfb", 6e5);
|
|
4365
|
+
const fcp = num("fcp", 6e5);
|
|
4366
|
+
const lcp = num("lcp", 6e5);
|
|
4367
|
+
const cls = num("cls", 100);
|
|
4368
|
+
const inp = num("inp", 6e5);
|
|
4369
|
+
if (ttfb !== void 0) sample.ttfb = ttfb;
|
|
4370
|
+
if (fcp !== void 0) sample.fcp = fcp;
|
|
4371
|
+
if (lcp !== void 0) sample.lcp = lcp;
|
|
4372
|
+
if (cls !== void 0) sample.cls = cls;
|
|
4373
|
+
if (inp !== void 0) sample.inp = inp;
|
|
4374
|
+
if (Object.keys(sample).some((k) => k !== "at")) {
|
|
4375
|
+
let e = this.#vitals.get(page);
|
|
4376
|
+
if (!e) {
|
|
4377
|
+
if (this.#vitals.size >= MAX_VITALS_PATHS) {
|
|
4378
|
+
let oldest;
|
|
4379
|
+
let t = Infinity;
|
|
4380
|
+
for (const [k, x] of this.#vitals) if (x.last < t) {
|
|
4381
|
+
t = x.last;
|
|
4382
|
+
oldest = k;
|
|
4383
|
+
}
|
|
4384
|
+
if (oldest !== void 0) this.#vitals.delete(oldest);
|
|
4385
|
+
}
|
|
4386
|
+
this.#vitals.set(page, e = {
|
|
4387
|
+
samples: [],
|
|
4388
|
+
last: now
|
|
4389
|
+
});
|
|
4390
|
+
}
|
|
4391
|
+
e.last = now;
|
|
4392
|
+
const same = sample.at !== void 0 ? e.samples.findIndex((s) => s.at === sample.at) : -1;
|
|
4393
|
+
if (same !== -1) e.samples[same] = {
|
|
4394
|
+
...e.samples[same],
|
|
4395
|
+
...sample
|
|
4396
|
+
};
|
|
4397
|
+
else e.samples.push(sample);
|
|
4398
|
+
if (e.samples.length > MAX_VITALS_SAMPLES) e.samples.shift();
|
|
4399
|
+
}
|
|
4400
|
+
}
|
|
4401
|
+
for (const it of Array.isArray(islands) ? islands.slice(0, MAX_BEACON_ISLANDS) : []) {
|
|
4402
|
+
const fp = typeof it?.fp === "string" ? it.fp : "";
|
|
4403
|
+
const ms = Number(it?.ms);
|
|
4404
|
+
const load = Number(it?.load);
|
|
4405
|
+
if (!is_hex_id(fp) || !Number.isFinite(ms) || ms < 0 || ms > 6e5) continue;
|
|
4406
|
+
let agg = this.#beacons.get(fp);
|
|
4407
|
+
if (!agg) {
|
|
4408
|
+
if (this.#beacons.size >= MAX_BEACON_FPS) {
|
|
4409
|
+
let oldest;
|
|
4410
|
+
let t = Infinity;
|
|
4411
|
+
for (const [k, v] of this.#beacons) if (v.last < t) {
|
|
4412
|
+
t = v.last;
|
|
4413
|
+
oldest = k;
|
|
4414
|
+
}
|
|
4415
|
+
if (oldest !== void 0) this.#beacons.delete(oldest);
|
|
4416
|
+
}
|
|
4417
|
+
this.#beacons.set(fp, agg = {
|
|
4418
|
+
entry: typeof it?.entry === "string" ? it.entry.slice(0, 300) : "",
|
|
4419
|
+
ms: [],
|
|
4420
|
+
load: [],
|
|
4421
|
+
recovered: 0,
|
|
4422
|
+
last: now
|
|
4423
|
+
});
|
|
4424
|
+
}
|
|
4425
|
+
agg.last = now;
|
|
4426
|
+
if (it?.recovered === true) agg.recovered++;
|
|
4427
|
+
if (typeof it?.reason === "string" && it.reason) agg.reason = it.reason.slice(0, 300);
|
|
4428
|
+
agg.ms.push(round2(ms));
|
|
4429
|
+
agg.load.push(Number.isFinite(load) && load >= 0 ? round2(Math.min(load, ms)) : 0);
|
|
4430
|
+
if (agg.ms.length > MAX_BEACON_SAMPLES) {
|
|
4431
|
+
agg.ms.shift();
|
|
4432
|
+
agg.load.shift();
|
|
4433
|
+
}
|
|
4434
|
+
}
|
|
4435
|
+
return new Response(null, {
|
|
4436
|
+
status: 204,
|
|
4437
|
+
headers: { "cache-control": "no-store" }
|
|
4438
|
+
});
|
|
4439
|
+
}
|
|
4440
|
+
/** The page the profiler's own user is about to view gets the beacon tag: the runtime posts its
|
|
4441
|
+
* hydration timings to `/beacon` when it sees it, and does nothing otherwise. The flag cookie
|
|
4442
|
+
* (set at login, site-wide, no secret) or the key header says it is that user; dev is open. The
|
|
4443
|
+
* tag goes at the end of `<head>` so the runtime bootstrap stays the first script. A visitor
|
|
4444
|
+
* never sees it; a stray flag with no session posts to a guarded route and gets nothing. */
|
|
4445
|
+
async #beacon_tag(event) {
|
|
4446
|
+
if (!this.ui_enabled) return null;
|
|
4447
|
+
if (event.request.headers.get("x-og-profiler-internal") === "1") return null;
|
|
4448
|
+
const dest = event.request.headers.get("sec-fetch-dest");
|
|
4449
|
+
if (dest ? dest !== "document" : !(event.request.headers.get("accept") ?? "").includes("text/html")) return null;
|
|
4450
|
+
if (!this.dev) {
|
|
4451
|
+
const key = event.request.headers.get("x-profiler-key");
|
|
4452
|
+
if (key ? !await this.#key_matches(key) : event.cookies.get("og_profiler_beacon") !== "1") return null;
|
|
4453
|
+
}
|
|
4454
|
+
return `<meta name="${BEACON_META}" content="${this.base}/beacon">${BEACON_STANDALONE_TAG}`;
|
|
4455
|
+
}
|
|
4456
|
+
handle = async ({ event, resolve }) => {
|
|
4457
|
+
if (this.#disabled) return resolve(event);
|
|
4458
|
+
this.#first_request_pt ??= performance.now();
|
|
4459
|
+
this.#requests_seen++;
|
|
4460
|
+
await this.#init();
|
|
4461
|
+
const on_ui_path = event.url.pathname === this.base || event.url.pathname.startsWith(this.base + "/");
|
|
4462
|
+
if (this.ui_enabled && on_ui_path) return this.#ui(event);
|
|
4463
|
+
if (!this.#disabled && !this.ui_enabled && !this.dev && this.secret === "" && on_ui_path) return new Response("ogygia profiler is installed but disabled: set OGYGIA_PROFILER_SECRET (or `ogygia({ profiler: { secret } })`) to enable the UI in production.", {
|
|
4464
|
+
status: 404,
|
|
4465
|
+
headers: {
|
|
4466
|
+
"content-type": "text/plain; charset=utf-8",
|
|
4467
|
+
"cache-control": "no-store"
|
|
4468
|
+
}
|
|
4469
|
+
});
|
|
4470
|
+
const remote = event.isRemoteRequest === true;
|
|
4471
|
+
let remote_url = null;
|
|
4472
|
+
if (remote) try {
|
|
4473
|
+
remote_url = new URL(event.request.url);
|
|
4474
|
+
} catch {
|
|
4475
|
+
remote_url = null;
|
|
4476
|
+
}
|
|
4477
|
+
const entry = {
|
|
4478
|
+
ts: Date.now(),
|
|
4479
|
+
method: event.request.method,
|
|
4480
|
+
path: remote_url ? remote_url.pathname : event.url.pathname,
|
|
4481
|
+
...remote ? { remote: true } : {},
|
|
4482
|
+
route: event.route?.id ?? null,
|
|
4483
|
+
status: 0,
|
|
4484
|
+
ms: 0,
|
|
4485
|
+
cpu_ms: 0,
|
|
4486
|
+
inflight: this.#inflight,
|
|
4487
|
+
net_ms: 0,
|
|
4488
|
+
net_count: 0,
|
|
4489
|
+
internal: event.request.headers.get("x-og-profiler-internal") === "1" || void 0
|
|
4490
|
+
};
|
|
4491
|
+
try {
|
|
4492
|
+
const search = remote_url ? remote_url.search : event.url.search;
|
|
4493
|
+
if (search) entry.search = search;
|
|
4494
|
+
} catch {}
|
|
4495
|
+
if (this.#trap?.replay?.length) {
|
|
4496
|
+
const kept = {};
|
|
4497
|
+
for (const h of this.#trap.replay) {
|
|
4498
|
+
const v = event.request.headers.get(h);
|
|
4499
|
+
if (v !== null) kept[h.toLowerCase()] = v.slice(0, 2048);
|
|
4500
|
+
}
|
|
4501
|
+
if (Object.keys(kept).length) entry.replay_headers = kept;
|
|
4502
|
+
}
|
|
4503
|
+
const profile_header = event.request.headers.get("x-profile");
|
|
4504
|
+
const header_profile = !!profile_header && this.ui_enabled && await this.#key_matches(profile_header) && this.#try_acquire_recorder();
|
|
4505
|
+
const ctx = !!this.#als && (this.dev || this.#recording_active() && !this.#trap_running || header_profile) ? this.#request_ctx(entry) : null;
|
|
4506
|
+
const answers = this.#answers;
|
|
4507
|
+
if (ctx && answers && this.#kept && event.request.headers.get(ANSWERS_HEADER) === answers.token) {
|
|
4508
|
+
const kept = this.#kept;
|
|
4509
|
+
ctx.answer = (method, url) => {
|
|
4510
|
+
const key = `${method} ${url}`;
|
|
4511
|
+
return answers.keys.has(key) ? kept.get(key) : void 0;
|
|
4512
|
+
};
|
|
4513
|
+
}
|
|
4514
|
+
const start = performance.now();
|
|
4515
|
+
entry.pt = start;
|
|
4516
|
+
const cpu0 = process.cpuUsage();
|
|
4517
|
+
this.#inflight++;
|
|
4518
|
+
if (ctx && this.#wrap_event_fetch && typeof event.fetch === "function") try {
|
|
4519
|
+
event.fetch = this.#wrap_event_fetch(event.fetch);
|
|
4520
|
+
} catch {}
|
|
4521
|
+
const beacon_tag = await this.#beacon_tag(event);
|
|
4522
|
+
const resolve_page = () => beacon_tag ? resolve(event, { transformPageChunk: ({ html }) => {
|
|
4523
|
+
let at = html.indexOf("</head>");
|
|
4524
|
+
if (at === -1) at = html.indexOf("</HEAD>");
|
|
4525
|
+
return at === -1 ? html : html.slice(0, at) + beacon_tag + html.slice(at);
|
|
4526
|
+
} }) : resolve(event);
|
|
4527
|
+
const run = async () => {
|
|
4528
|
+
const res = ctx && this.#als ? await this.#als.run(ctx, resolve_page) : await resolve_page();
|
|
4529
|
+
entry.status = res.status;
|
|
4530
|
+
if (beacon_tag && this.#client_cpu) try {
|
|
4531
|
+
res.headers.set("document-policy", "js-profiling");
|
|
4532
|
+
} catch {}
|
|
4533
|
+
if (beacon_tag && !this.dev) mark_personal(res);
|
|
4534
|
+
if (this.want_server_timing) try {
|
|
4535
|
+
const ms = performance.now() - start;
|
|
4536
|
+
res.headers.append("Server-Timing", `ssr;desc="SvelteKit render";dur=${ms.toFixed(1)}`);
|
|
4537
|
+
try {
|
|
4538
|
+
const c = process.cpuUsage(cpu0);
|
|
4539
|
+
res.headers.append("Server-Timing", `cpu;desc="process CPU while it ran";dur=${((c.user + c.system) / 1e3).toFixed(1)}`);
|
|
4540
|
+
} catch {}
|
|
4541
|
+
if (ctx?.net.length) {
|
|
4542
|
+
const net_ms = ctx.net.reduce((a, c) => a + Math.max(c.ms, 0) + (c.body_ms ?? 0), 0);
|
|
4543
|
+
res.headers.append("Server-Timing", `net;desc="outbound (${ctx.net.length})";dur=${net_ms.toFixed(1)}`);
|
|
4544
|
+
[...ctx.net].sort((x, y) => y.ms + (y.body_ms ?? 0) - (x.ms + (x.body_ms ?? 0))).slice(0, 3).forEach((x, i) => {
|
|
4545
|
+
let where = x.url.split("?")[0].split("#")[0];
|
|
4546
|
+
try {
|
|
4547
|
+
const u = new URL(x.url, "http://relative.invalid");
|
|
4548
|
+
where = (u.host === "relative.invalid" ? "" : u.host) + u.pathname;
|
|
4549
|
+
} catch {}
|
|
4550
|
+
const desc = `${x.method ?? "GET"} ${where}`.slice(0, 120).split("\"").join("'").split("\\").join("/");
|
|
4551
|
+
res.headers.append("Server-Timing", `up${i + 1};desc="${desc}";dur=${(Math.max(x.ms, 0) + (x.body_ms ?? 0)).toFixed(1)}`);
|
|
4552
|
+
});
|
|
4553
|
+
}
|
|
4554
|
+
if (event.request.headers.get("x-og-trace")) {
|
|
4555
|
+
const c = process.cpuUsage(cpu0);
|
|
4556
|
+
const net = ctx?.net ?? [];
|
|
4557
|
+
res.headers.set(TRACE_HEADER, encode_trace({
|
|
4558
|
+
ms: round2(ms),
|
|
4559
|
+
cpu_ms: round2((c.user + c.system) / 1e3),
|
|
4560
|
+
wait_ms: round2(net.reduce((a, x) => a + Math.max(x.ms, 0) + (x.body_ms ?? 0), 0)),
|
|
4561
|
+
calls: net.length,
|
|
4562
|
+
route: event.route?.id ?? null,
|
|
4563
|
+
top: [...net].sort((x, y) => y.ms - x.ms).slice(0, 5).map((x) => ({
|
|
4564
|
+
url: x.url.slice(0, 200),
|
|
4565
|
+
ms: round2(x.ms + (x.body_ms ?? 0))
|
|
4566
|
+
})),
|
|
4567
|
+
profiler: this.base
|
|
4568
|
+
}));
|
|
4569
|
+
}
|
|
4570
|
+
} catch {}
|
|
4571
|
+
return res;
|
|
4572
|
+
};
|
|
4573
|
+
if (header_profile) {
|
|
4574
|
+
this.#recording_since = Date.now();
|
|
4575
|
+
const watchdog = setTimeout(() => {
|
|
4576
|
+
this.#recording_since = 0;
|
|
4577
|
+
this.#release_als();
|
|
4578
|
+
}, 32e3);
|
|
4579
|
+
watchdog.unref?.();
|
|
4580
|
+
try {
|
|
4581
|
+
let res;
|
|
4582
|
+
const cap = await this.#capture_window(100, async () => {
|
|
4583
|
+
res = await run();
|
|
4584
|
+
});
|
|
4585
|
+
this.#finalize(entry, start, cpu0, ctx, event.request);
|
|
4586
|
+
cap.window = {
|
|
4587
|
+
start,
|
|
4588
|
+
end: start + entry.ms
|
|
4589
|
+
};
|
|
4590
|
+
const id = await this.#finish_report(cap, {
|
|
4591
|
+
trigger: "request",
|
|
4592
|
+
request: {
|
|
4593
|
+
method: entry.method,
|
|
4594
|
+
path: entry.path,
|
|
4595
|
+
route: entry.route,
|
|
4596
|
+
ms: entry.ms
|
|
4597
|
+
}
|
|
4598
|
+
});
|
|
4599
|
+
const stored = this.#reports.get(id);
|
|
4600
|
+
if (stored) stored.replay = {
|
|
4601
|
+
path: entry.path + (entry.search ?? ""),
|
|
4602
|
+
headers: entry.replay_headers ?? {}
|
|
4603
|
+
};
|
|
4604
|
+
try {
|
|
4605
|
+
res?.headers.append("x-profile-report", `${this.base}/report/${id}`);
|
|
4606
|
+
} catch {}
|
|
4607
|
+
return res;
|
|
4608
|
+
} finally {
|
|
4609
|
+
clearTimeout(watchdog);
|
|
4610
|
+
this.#recording_since = 0;
|
|
4611
|
+
this.#release_als();
|
|
4612
|
+
this.#release_recorder();
|
|
4613
|
+
}
|
|
4614
|
+
}
|
|
4615
|
+
try {
|
|
4616
|
+
return await run();
|
|
4617
|
+
} finally {
|
|
4618
|
+
this.#finalize(entry, start, cpu0, ctx, event.request);
|
|
4619
|
+
if (event.request.headers.has(WINDOW_HEADER)) await this.#window_note(entry, event.request);
|
|
4620
|
+
}
|
|
4621
|
+
};
|
|
4622
|
+
#finalize(entry, start, cpu0, ctx, request) {
|
|
4623
|
+
this.#inflight--;
|
|
4624
|
+
entry.ms = round2(performance.now() - start);
|
|
4625
|
+
const c = process.cpuUsage(cpu0);
|
|
4626
|
+
entry.cpu_ms = round2((c.user + c.system) / 1e3);
|
|
4627
|
+
const og = request ? request_stats_of(request) : void 0;
|
|
4628
|
+
if (og) entry.og = og;
|
|
4629
|
+
const hole = request ? hole_stats_of(request) : void 0;
|
|
4630
|
+
if (hole) entry.hole = hole;
|
|
4631
|
+
if (ctx) {
|
|
4632
|
+
entry.net_count = ctx.net.length;
|
|
4633
|
+
entry.net_ms = round2(ctx.net.reduce((a, c) => a + Math.max(c.ms, 0) + (c.body_ms ?? 0), 0));
|
|
4634
|
+
if (ctx.spans.length) {
|
|
4635
|
+
entry.span_count = ctx.spans.length;
|
|
4636
|
+
entry.span_ms = round2(ctx.spans.filter((s) => s.parent === void 0 && s.ms >= 0).reduce((a, s) => a + s.ms, 0));
|
|
4637
|
+
}
|
|
4638
|
+
}
|
|
4639
|
+
this.#ring.push(entry);
|
|
4640
|
+
if (this.#ring.length > this.ring_size) this.#ring.shift();
|
|
4641
|
+
if (request) {
|
|
4642
|
+
this.#logged.set(request, entry);
|
|
4643
|
+
const early = this.#early_holes.get(request);
|
|
4644
|
+
if (early) {
|
|
4645
|
+
this.#early_holes.delete(request);
|
|
4646
|
+
for (const s of early) this.#batch_hole(request, s);
|
|
4647
|
+
}
|
|
4648
|
+
}
|
|
4649
|
+
if (this.#sink_url && !entry.internal) {
|
|
4650
|
+
this.#sink.push({
|
|
4651
|
+
k: "req",
|
|
4652
|
+
t: entry.ts,
|
|
4653
|
+
path: entry.path,
|
|
4654
|
+
route: entry.route,
|
|
4655
|
+
ms: entry.ms,
|
|
4656
|
+
cpu: entry.cpu_ms,
|
|
4657
|
+
wait: entry.net_ms,
|
|
4658
|
+
status: entry.status,
|
|
4659
|
+
...entry.og ? { og: entry.og.seed_bytes + entry.og.tail_bytes } : {}
|
|
4660
|
+
});
|
|
4661
|
+
if (Number.isFinite(serverless_work_budget_ms())) this.#sink_flush();
|
|
4662
|
+
}
|
|
4663
|
+
}
|
|
4664
|
+
/** Post the buffered rows as NDJSON to the sink. One post in flight at a time; a failure keeps
|
|
4665
|
+
* the rows for the next try (the buffer drops its oldest request rows when full). */
|
|
4666
|
+
async #sink_flush() {
|
|
4667
|
+
if (this.#sink_timer && this.#superseded()) {
|
|
4668
|
+
clearInterval(this.#sink_timer);
|
|
4669
|
+
this.#sink_timer = null;
|
|
4670
|
+
}
|
|
4671
|
+
if (!this.#sink_url || this.#sink_inflight || !this.#sink.size) return;
|
|
4672
|
+
this.#sink_inflight = true;
|
|
4673
|
+
const rows = this.#sink.size;
|
|
4674
|
+
const body = this.#sink.drain();
|
|
4675
|
+
try {
|
|
4676
|
+
const res = await fetch(this.#sink_url, {
|
|
4677
|
+
method: "POST",
|
|
4678
|
+
body,
|
|
4679
|
+
headers: {
|
|
4680
|
+
"content-type": "application/x-ndjson",
|
|
4681
|
+
"x-og-profiler-internal": "1",
|
|
4682
|
+
...this.#sink_key ? { authorization: `Bearer ${this.#sink_key}` } : {}
|
|
4683
|
+
},
|
|
4684
|
+
signal: AbortSignal.timeout(5e3)
|
|
4685
|
+
});
|
|
4686
|
+
this.#sink_last = {
|
|
4687
|
+
at: Date.now(),
|
|
4688
|
+
ok: res.ok,
|
|
4689
|
+
rows,
|
|
4690
|
+
...res.ok ? {} : { error: `the sink answered ${res.status}` }
|
|
4691
|
+
};
|
|
4692
|
+
if (!res.ok) {
|
|
4693
|
+
for (const line of body.split("\n")) if (line) this.#sink.push(JSON.parse(line));
|
|
4694
|
+
}
|
|
4695
|
+
} catch (e) {
|
|
4696
|
+
this.#sink_last = {
|
|
4697
|
+
at: Date.now(),
|
|
4698
|
+
ok: false,
|
|
4699
|
+
rows,
|
|
4700
|
+
error: e instanceof Error ? e.message : String(e)
|
|
4701
|
+
};
|
|
4702
|
+
for (const line of body.split("\n")) if (line) this.#sink.push(JSON.parse(line));
|
|
4703
|
+
} finally {
|
|
4704
|
+
this.#sink_inflight = false;
|
|
4705
|
+
}
|
|
4706
|
+
}
|
|
4707
|
+
/** The site view's rows from THIS instance: the request ring and the sampler's windows — what a
|
|
4708
|
+
* long-lived host has without a sink. */
|
|
4709
|
+
#site_rows() {
|
|
4710
|
+
const rows = [];
|
|
4711
|
+
for (const e of this.#ring) if (!e.internal) rows.push({
|
|
4712
|
+
k: "req",
|
|
4713
|
+
t: e.ts,
|
|
4714
|
+
path: e.path,
|
|
4715
|
+
route: e.route,
|
|
4716
|
+
ms: e.ms,
|
|
4717
|
+
cpu: e.cpu_ms,
|
|
4718
|
+
wait: e.net_ms,
|
|
4719
|
+
status: e.status,
|
|
4720
|
+
...e.og ? { og: e.og.seed_bytes + e.og.tail_bytes } : {}
|
|
4721
|
+
});
|
|
4722
|
+
for (const w of this.#sampled) rows.push({
|
|
4723
|
+
k: "win",
|
|
4724
|
+
t0: w.at,
|
|
4725
|
+
t1: w.at + w.ms,
|
|
4726
|
+
fns: w.functions.slice(0, 40).map((f) => ({
|
|
4727
|
+
name: f.name,
|
|
4728
|
+
file: f.url,
|
|
4729
|
+
self_ms: f.self_ms,
|
|
4730
|
+
category: f.category
|
|
4731
|
+
}))
|
|
4732
|
+
});
|
|
4733
|
+
for (const r of this.#reports.values()) if (r.meta.trigger === "trap" && r.meta.request) rows.push({
|
|
4734
|
+
k: "trap",
|
|
4735
|
+
t: r.meta.created,
|
|
4736
|
+
path: r.meta.request.path,
|
|
4737
|
+
ms: r.meta.request.ms,
|
|
4738
|
+
id: r.meta.id
|
|
4739
|
+
});
|
|
4740
|
+
return rows;
|
|
4741
|
+
}
|
|
4742
|
+
/** `/site`: the whole-site pictures (the request cloud, the layer cake) from this instance's
|
|
4743
|
+
* rows, or from a sink the browser reads (`?from=<url>`, or the configured sink). */
|
|
4744
|
+
#site(ctx) {
|
|
4745
|
+
const from = ctx.url.searchParams.get("from");
|
|
4746
|
+
return {
|
|
4747
|
+
base: this.base,
|
|
4748
|
+
rows: this.#site_rows(),
|
|
4749
|
+
sink: this.#sink_url ? {
|
|
4750
|
+
url: this.#sink_url,
|
|
4751
|
+
last: this.#sink_last,
|
|
4752
|
+
buffered: this.#sink.size
|
|
4753
|
+
} : null,
|
|
4754
|
+
from: from && /^https?:\/\//.test(from) ? from.slice(0, 2e3) : null,
|
|
4755
|
+
ephemeral: Number.isFinite(serverless_work_budget_ms())
|
|
4756
|
+
};
|
|
4757
|
+
}
|
|
4758
|
+
};
|
|
4759
|
+
/**
|
|
4760
|
+
* Build a profiler handle. INTERNAL — the profiler is configured in vite.config.ts
|
|
4761
|
+
* (`ogygia({ profiler: true })`) and `ogygia.handle()` constructs + mounts this itself.
|
|
4762
|
+
* Kept as a named export only so the handle's lazy import can reach it; it is not a
|
|
4763
|
+
* public entry point.
|
|
4764
|
+
*
|
|
4765
|
+
* See the class doc and README for what it captures. Visit `<path>` (default
|
|
4766
|
+
* /__profiler) — in prod, log in (or send the x-profiler-key header).
|
|
4767
|
+
*/
|
|
4768
|
+
function profiler(options = {}) {
|
|
4769
|
+
const instance = new Profiler(options);
|
|
4770
|
+
return (args) => instance.handle(args);
|
|
4771
|
+
}
|
|
4772
|
+
function route_aggregates(ring, by = null) {
|
|
4773
|
+
const by_route = /* @__PURE__ */ new Map();
|
|
4774
|
+
for (const e of ring) {
|
|
4775
|
+
if (e.internal) continue;
|
|
4776
|
+
const key = (e.route ?? "(no route)") + (by ? ` · ${by}=${e.tags?.[by] ?? "—"}` : "");
|
|
4777
|
+
let list = by_route.get(key);
|
|
4778
|
+
if (!list) by_route.set(key, list = []);
|
|
4779
|
+
list.push(e);
|
|
4780
|
+
}
|
|
4781
|
+
const aggs = [];
|
|
4782
|
+
for (const [route, list] of by_route) {
|
|
4783
|
+
const sorted = list.map((e) => e.ms).sort((a, b) => a - b);
|
|
4784
|
+
const net = list.map((e) => e.net_ms).sort((a, b) => a - b);
|
|
4785
|
+
aggs.push({
|
|
4786
|
+
route,
|
|
4787
|
+
count: list.length,
|
|
4788
|
+
p50: pct(sorted, 50),
|
|
4789
|
+
p95: pct(sorted, 95),
|
|
4790
|
+
max: sorted.at(-1) ?? 0,
|
|
4791
|
+
avg: round2(sorted.reduce((a, c) => a + c, 0) / (sorted.length || 1)),
|
|
4792
|
+
net_p50: pct(net, 50)
|
|
4793
|
+
});
|
|
4794
|
+
}
|
|
4795
|
+
return aggs.sort((a, b) => b.p95 - a.p95);
|
|
4796
|
+
}
|
|
4797
|
+
function pct(sorted, p) {
|
|
4798
|
+
if (!sorted.length) return 0;
|
|
4799
|
+
return sorted[Math.min(sorted.length - 1, Math.floor(p / 100 * sorted.length))];
|
|
4800
|
+
}
|
|
4801
|
+
function clamp(n, lo, hi) {
|
|
4802
|
+
return Math.max(lo, Math.min(hi, Number.isFinite(n) ? n : lo));
|
|
4803
|
+
}
|
|
4804
|
+
/** Race a promise against a wall-time so a hung render can never pin a recording (and, through the
|
|
4805
|
+
* attribution AsyncLocalStorage, the whole site). Rejects with `label` on timeout; the underlying work
|
|
4806
|
+
* is left to settle on its own — the caller has already moved on. */
|
|
4807
|
+
function with_timeout(p, ms, label) {
|
|
4808
|
+
if (!Number.isFinite(ms) || ms <= 0) return p;
|
|
4809
|
+
let timer;
|
|
4810
|
+
const timeout = new Promise((_, reject) => {
|
|
4811
|
+
timer = setTimeout(() => reject(new Error(label)), ms);
|
|
4812
|
+
timer.unref?.();
|
|
4813
|
+
});
|
|
4814
|
+
return Promise.race([p.finally(() => clearTimeout(timer)), timeout]);
|
|
4815
|
+
}
|
|
4816
|
+
/** OGYGIA_PROFILER_DEBUG: the process's memory at a step of a recording (what the profiler itself
|
|
4817
|
+
* holds on a small serverless instance) */
|
|
4818
|
+
function mem_mark(step) {
|
|
4819
|
+
if (!process.env.OGYGIA_PROFILER_DEBUG) return;
|
|
4820
|
+
const m = process.memoryUsage();
|
|
4821
|
+
const mb = (n) => Math.round(n / 1048576);
|
|
4822
|
+
console.error(`[ogygia/profiler] memory ${step} @${Math.round(performance.now())} ms: rss ${mb(m.rss)} heap ${mb(m.heapUsed)} external ${mb(m.external)} buffers ${mb(m.arrayBuffers)} MB`);
|
|
4823
|
+
}
|
|
4824
|
+
function round2(n) {
|
|
4825
|
+
return Math.round(n * 100) / 100;
|
|
4826
|
+
}
|
|
4827
|
+
/** What names a report: kept whatever `fields` asks for. */
|
|
4828
|
+
const REPORT_IDENTITY = [
|
|
4829
|
+
"schema",
|
|
4830
|
+
"version",
|
|
4831
|
+
"engine",
|
|
4832
|
+
"kind",
|
|
4833
|
+
"id",
|
|
4834
|
+
"created",
|
|
4835
|
+
"target",
|
|
4836
|
+
"links"
|
|
4837
|
+
];
|
|
4838
|
+
/** `report` with only the top-level keys in `fields` (comma-separated) and its identity; whole when unset. */
|
|
4839
|
+
function pick_fields(report, fields) {
|
|
4840
|
+
if (!fields) return report;
|
|
4841
|
+
const want = /* @__PURE__ */ new Set([...REPORT_IDENTITY, ...fields.split(",").map((f) => f.trim()).filter(Boolean)]);
|
|
4842
|
+
const out = {};
|
|
4843
|
+
for (const k in report) if (want.has(k)) out[k] = report[k];
|
|
4844
|
+
return out;
|
|
4845
|
+
}
|
|
4846
|
+
/**
|
|
4847
|
+
* The module (src-relative, no extension: `lib/inferno/catalog`) a file imports `name` from — its
|
|
4848
|
+
* `import { name } from '$lib/…'` or `'./…'` — or the file itself when it declares it. Undefined for
|
|
4849
|
+
* a package import, or a name it cannot place. Plain text, no regex.
|
|
4850
|
+
*/
|
|
4851
|
+
/**
|
|
4852
|
+
* Is the route in `dir` prerendered? Kit takes the NEAREST `export const prerender`: the page's own
|
|
4853
|
+
* files first (`+page.server`, `+page`), then each layout up the tree. `true` and `'auto'` prerender;
|
|
4854
|
+
* `false` (or none found) does not. String search over the files (no regex, no parse).
|
|
4855
|
+
*/
|
|
4856
|
+
function route_prerendered(read, dir) {
|
|
4857
|
+
let at = dir;
|
|
4858
|
+
let first = true;
|
|
4859
|
+
while (at) {
|
|
4860
|
+
const names = [
|
|
4861
|
+
...first ? [
|
|
4862
|
+
"+page.server.ts",
|
|
4863
|
+
"+page.server.js",
|
|
4864
|
+
"+page.ts",
|
|
4865
|
+
"+page.js"
|
|
4866
|
+
] : [],
|
|
4867
|
+
"+layout.server.ts",
|
|
4868
|
+
"+layout.server.js",
|
|
4869
|
+
"+layout.ts",
|
|
4870
|
+
"+layout.js"
|
|
4871
|
+
];
|
|
4872
|
+
for (const n of names) {
|
|
4873
|
+
const src = read(`${at}/${n}`);
|
|
4874
|
+
if (!src) continue;
|
|
4875
|
+
const k = src.indexOf("export const prerender");
|
|
4876
|
+
if (k === -1) continue;
|
|
4877
|
+
const eq = src.indexOf("=", k);
|
|
4878
|
+
const v = eq === -1 ? "" : src.slice(eq + 1, eq + 12).trim();
|
|
4879
|
+
return v.startsWith("true") || v.startsWith("'auto'") || v.startsWith("\"auto\"");
|
|
4880
|
+
}
|
|
4881
|
+
first = false;
|
|
4882
|
+
const up = at.lastIndexOf("/");
|
|
4883
|
+
if (up === -1) break;
|
|
4884
|
+
at = at.slice(0, up);
|
|
4885
|
+
}
|
|
4886
|
+
return false;
|
|
4887
|
+
}
|
|
4888
|
+
/** An island entry as a path, however it was written (`./_app/x.js`, `/_app/x.js`, an absolute URL). */
|
|
4889
|
+
function entry_path(entry) {
|
|
4890
|
+
try {
|
|
4891
|
+
return new URL(entry, "http://x/").pathname;
|
|
4892
|
+
} catch {
|
|
4893
|
+
return entry;
|
|
4894
|
+
}
|
|
4895
|
+
}
|
|
4896
|
+
function imported_from(src, name, file) {
|
|
4897
|
+
const self = file.slice(0, file.lastIndexOf("."));
|
|
4898
|
+
for (const line of src.split("\n")) {
|
|
4899
|
+
const l = line.trim();
|
|
4900
|
+
if (!l.startsWith("import")) {
|
|
4901
|
+
if ((l.startsWith(`function ${name}(`) || l.startsWith(`export function ${name}(`) || l.startsWith(`async function ${name}(`) || l.startsWith(`export async function ${name}(`) || l.startsWith(`const ${name} =`)) && !l.startsWith("import")) return self;
|
|
4902
|
+
continue;
|
|
4903
|
+
}
|
|
4904
|
+
const open = l.indexOf("{");
|
|
4905
|
+
const close = l.indexOf("}");
|
|
4906
|
+
if (!(open !== -1 && close > open ? l.slice(open + 1, close).split(",").map((x) => x.trim().split(" as ").pop().trim()) : []).includes(name)) continue;
|
|
4907
|
+
const q = Math.max(l.lastIndexOf("'"), l.lastIndexOf("\""));
|
|
4908
|
+
const q0 = Math.max(l.lastIndexOf("'", q - 1), l.lastIndexOf("\"", q - 1));
|
|
4909
|
+
const spec = q0 !== -1 && q > q0 ? l.slice(q0 + 1, q) : "";
|
|
4910
|
+
const bare = (p) => p.endsWith(".ts") || p.endsWith(".js") ? p.slice(0, -3) : p;
|
|
4911
|
+
if (spec.startsWith("$lib/")) return bare(`lib/${spec.slice(5)}`);
|
|
4912
|
+
if (spec.startsWith(".")) {
|
|
4913
|
+
const parts = file.split("/").slice(0, -1);
|
|
4914
|
+
for (const seg of spec.split("/")) if (seg === "..") parts.pop();
|
|
4915
|
+
else if (seg !== ".") parts.push(seg);
|
|
4916
|
+
return bare(parts.join("/"));
|
|
4917
|
+
}
|
|
4918
|
+
return;
|
|
4919
|
+
}
|
|
4920
|
+
}
|
|
4921
|
+
export { pick_fields, profiler, route_prerendered, self_profile_to_cpuprofile };
|