react-render-pulse 0.0.0-stage → 1.0.1

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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +318 -2
  3. package/dist/aggregate/aggregator.d.cts +27 -0
  4. package/dist/aggregate/aggregator.d.ts +28 -0
  5. package/dist/aggregate/aggregator.d.ts.map +1 -0
  6. package/dist/aggregate/histogram.d.cts +18 -0
  7. package/dist/aggregate/histogram.d.ts +19 -0
  8. package/dist/aggregate/histogram.d.ts.map +1 -0
  9. package/dist/components/RenderMonitor.d.cts +19 -0
  10. package/dist/components/RenderMonitor.d.ts +20 -0
  11. package/dist/components/RenderMonitor.d.ts.map +1 -0
  12. package/dist/context/device.d.cts +2 -0
  13. package/dist/context/device.d.ts +3 -0
  14. package/dist/context/device.d.ts.map +1 -0
  15. package/dist/context/route.d.cts +7 -0
  16. package/dist/context/route.d.ts +8 -0
  17. package/dist/context/route.d.ts.map +1 -0
  18. package/dist/core.cjs +56 -0
  19. package/dist/core.cjs.map +1 -0
  20. package/dist/core.d.cts +2 -0
  21. package/dist/core.d.ts +3 -0
  22. package/dist/core.d.ts.map +1 -0
  23. package/dist/core.js +28 -0
  24. package/dist/core.js.map +1 -0
  25. package/dist/export/console.d.cts +3 -0
  26. package/dist/export/console.d.ts +4 -0
  27. package/dist/export/console.d.ts.map +1 -0
  28. package/dist/export/otel.d.cts +34 -0
  29. package/dist/export/otel.d.ts +35 -0
  30. package/dist/export/otel.d.ts.map +1 -0
  31. package/dist/hooks/useRenderMonitor.d.cts +9 -0
  32. package/dist/hooks/useRenderMonitor.d.ts +10 -0
  33. package/dist/hooks/useRenderMonitor.d.ts.map +1 -0
  34. package/dist/index.cjs +634 -0
  35. package/dist/index.cjs.map +1 -0
  36. package/dist/index.d.cts +8 -0
  37. package/dist/index.d.ts +9 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +602 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/modes/detect.d.cts +16 -0
  42. package/dist/modes/detect.d.ts +17 -0
  43. package/dist/modes/detect.d.ts.map +1 -0
  44. package/dist/observe/longtasks.d.cts +12 -0
  45. package/dist/observe/longtasks.d.ts +13 -0
  46. package/dist/observe/longtasks.d.ts.map +1 -0
  47. package/dist/observe/renderWindows.d.cts +17 -0
  48. package/dist/observe/renderWindows.d.ts +18 -0
  49. package/dist/observe/renderWindows.d.ts.map +1 -0
  50. package/dist/otel.cjs +65 -0
  51. package/dist/otel.cjs.map +1 -0
  52. package/dist/otel.d.cts +2 -0
  53. package/dist/otel.d.ts +3 -0
  54. package/dist/otel.d.ts.map +1 -0
  55. package/dist/otel.js +38 -0
  56. package/dist/otel.js.map +1 -0
  57. package/dist/runtime/guard.d.cts +7 -0
  58. package/dist/runtime/guard.d.ts +8 -0
  59. package/dist/runtime/guard.d.ts.map +1 -0
  60. package/dist/runtime/initRenderPulse.d.cts +44 -0
  61. package/dist/runtime/initRenderPulse.d.ts +45 -0
  62. package/dist/runtime/initRenderPulse.d.ts.map +1 -0
  63. package/dist/runtime/runtime.d.cts +13 -0
  64. package/dist/runtime/runtime.d.ts +14 -0
  65. package/dist/runtime/runtime.d.ts.map +1 -0
  66. package/dist/runtime/useIsomorphicLayoutEffect.d.cts +2 -0
  67. package/dist/runtime/useIsomorphicLayoutEffect.d.ts +3 -0
  68. package/dist/runtime/useIsomorphicLayoutEffect.d.ts.map +1 -0
  69. package/dist/sample/sample.d.cts +7 -0
  70. package/dist/sample/sample.d.ts +8 -0
  71. package/dist/sample/sample.d.ts.map +1 -0
  72. package/dist/transport/payload.d.cts +8 -0
  73. package/dist/transport/payload.d.ts +9 -0
  74. package/dist/transport/payload.d.ts.map +1 -0
  75. package/dist/transport/transport.d.cts +18 -0
  76. package/dist/transport/transport.d.ts +19 -0
  77. package/dist/transport/transport.d.ts.map +1 -0
  78. package/dist/types.d.cts +120 -0
  79. package/dist/types.d.ts +121 -0
  80. package/dist/types.d.ts.map +1 -0
  81. package/package.json +128 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Amal M
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,319 @@
1
- # Temporary Holding Version
1
+ # react-render-pulse
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/react-render-pulse?color=cb3837&logo=npm)](https://www.npmjs.com/package/react-render-pulse)
4
+ [![CI](https://github.com/Amalmktk/react-render-pulse/actions/workflows/ci.yml/badge.svg)](https://github.com/Amalmktk/react-render-pulse/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/react-render-pulse)](https://github.com/Amalmktk/react-render-pulse/blob/main/LICENSE)
6
+
7
+ **Find the React components that slow your app down — on real users' devices.**
8
+
9
+ React DevTools shows how your app performs on your machine. react-render-pulse shows how it performs for your users: it measures named areas of your component tree in production, summarizes the timings in the browser, and sends small batches to an endpoint you control. You get render counts, slow renders, p50/p75/p95 and the slowest renders per area and route, plus long tasks blamed on the area that was rendering, by device class and app version.
10
+
11
+ - About **10 µs per measured render** in lightweight mode, and **3.6 kB** (brotli) for the client
12
+ - **Privacy-friendly:** no user IDs, IP addresses, page text, input values or query strings
13
+ - **Never breaks your app:** every internal error is caught; missing browser APIs are handled
14
+ - React 18 and 19, ESM and CommonJS, fully typed, `"use client"` ready for Next.js
15
+
16
+ 📖 **Docs:** https://amalmktk.github.io/react-render-pulse/
17
+
18
+ **Contents:** [Install](#install) · [Quick start](#quick-start) · [How it works](#how-it-works) · [Collecting results](#collecting-results) · [API](#api) · [Modes](#modes) · [Frameworks](#frameworks) · [What gets sent](#what-gets-sent) · [Performance](#performance) · [Limitations](#limitations)
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install react-render-pulse
24
+ ```
25
+
26
+ React 18 or 19 is a peer dependency. There are no other runtime dependencies.
27
+
28
+ ## Quick start
29
+
30
+ **1. Start it once,** in the browser, before your app renders:
31
+
32
+ ```tsx
33
+ // src/main.tsx
34
+ import { createRoot } from "react-dom/client";
35
+ import { initRenderPulse } from "react-render-pulse";
36
+ import { App } from "./App";
37
+
38
+ initRenderPulse({
39
+ endpoint: "/api/render-pulse", // leave out to log batches to the console instead
40
+ sampleRate: 0.1, // 10% of page loads
41
+ appVersion: import.meta.env.VITE_APP_VERSION, // compare releases
42
+ });
43
+
44
+ createRoot(document.getElementById("root")!).render(<App />);
45
+ ```
46
+
47
+ **2. Wrap the areas you care about.** Areas can be nested:
48
+
49
+ ```tsx
50
+ import { RenderMonitor } from "react-render-pulse";
51
+
52
+ export function OrdersPage() {
53
+ return (
54
+ <RenderMonitor id="orders-page">
55
+ <Filters />
56
+ <RenderMonitor id="orders-table">
57
+ <OrdersTable />
58
+ </RenderMonitor>
59
+ </RenderMonitor>
60
+ );
61
+ }
62
+ ```
63
+
64
+ Or track a single component with the hook:
65
+
66
+ ```tsx
67
+ import { useRenderMonitor } from "react-render-pulse";
68
+
69
+ function PriceChart() {
70
+ useRenderMonitor("price-chart");
71
+ return <Chart />;
72
+ }
73
+ ```
74
+
75
+ **3. Try it.** Without an `endpoint`, use the page, then switch tabs: the batch is logged to the console as a table, slowest areas first. With an `endpoint`, it's `POST`ed there; see [Collecting results](#collecting-results).
76
+
77
+ `<RenderMonitor>` adds no DOM elements and never remounts its children, in any mode, whether or not the session is sampled.
78
+
79
+ ## How it works
80
+
81
+ ```
82
+ <RenderMonitor> / useRenderMonitor ──► in-memory aggregator ──► batch ──► your endpoint ──► storage ──► viewer / SQL
83
+ render timings per area + route: sent on page hide (react-render-pulse-collector)
84
+ PerformanceObserver ──► long tasks histogram, counts, (sendBeacon) and every
85
+ and long animation frames, worst cases 3 minutes (fetch keepalive)
86
+ blamed on the area rendering
87
+ ```
88
+
89
+ 1. **Sample:** once per page load, decide whether to measure (`sampleRate`). Unsampled sessions do almost nothing.
90
+ 2. **Measure:** time each monitored area's renders (see [Modes](#modes)) and observe long tasks.
91
+ 3. **Aggregate:** keep counts, a 16-bucket histogram and the 5 slowest renders per area + route, never raw events. Memory stays flat.
92
+ 4. **Send:** when the page is hidden, and every 3 minutes while it's open. Batches are trimmed to 60 KB, sent with `sendBeacon` (falling back to `fetch` with `keepalive`), and retried on `429`/`5xx`.
93
+
94
+ Full details: [Architecture](https://amalmktk.github.io/react-render-pulse/guide/architecture).
95
+
96
+ ## Collecting results
97
+
98
+ Batches need somewhere to go. You can use the companion packages, your own endpoint, or OpenTelemetry.
99
+
100
+ **With [`react-render-pulse-collector`](https://www.npmjs.com/package/react-render-pulse-collector)**, a standard `Request` → `Response` handler that validates and stores batches. On Next.js:
101
+
102
+ ```ts
103
+ // app/api/render-pulse/route.ts
104
+ import { createCollector } from "react-render-pulse-collector";
105
+ import { postgresStorage } from "react-render-pulse-collector/postgres";
106
+
107
+ const collect = createCollector({ storage: postgresStorage({ url: process.env.DATABASE_URL! }) });
108
+
109
+ export const POST = collect;
110
+ export const OPTIONS = collect;
111
+ ```
112
+
113
+ It also runs on Vercel, Netlify, Cloudflare Workers and Express, and stores to Postgres or a JSON Lines file.
114
+
115
+ **Read them with [`react-render-pulse-viewer`](https://www.npmjs.com/package/react-render-pulse-viewer):**
116
+
117
+ ```bash
118
+ npx react-render-pulse-viewer report --db "$DATABASE_URL" --since 7d # terminal tables
119
+ npx react-render-pulse-viewer serve --db "$DATABASE_URL" # web viewer on 127.0.0.1:4173
120
+ ```
121
+
122
+ **Or query Postgres yourself.** Each batch is one row with the payload as `jsonb`:
123
+
124
+ ```sql
125
+ SELECT a->>'id' AS area, a->>'route' AS route,
126
+ sum((a->>'renders')::int) AS renders,
127
+ round(100.0 * sum((a->>'slow')::int) / nullif(sum((a->>'renders')::int), 0), 1) AS slow_pct
128
+ FROM render_pulse_batches b
129
+ CROSS JOIN LATERAL jsonb_array_elements(b.payload->'areas') AS a
130
+ WHERE b.received_at > now() - interval '7 days'
131
+ GROUP BY 1, 2
132
+ ORDER BY slow_pct DESC NULLS LAST;
133
+ ```
134
+
135
+ **Or send to OpenTelemetry** with `createOtelExporter` (below). No collector is needed.
136
+
137
+ Step-by-step guide, more SQL, and troubleshooting: [Collecting results](https://amalmktk.github.io/react-render-pulse/guide/collecting-results).
138
+
139
+ ## API
140
+
141
+ ### `initRenderPulse(options?)`
142
+
143
+ Starts measuring. Call it once, in the browser, before your app renders. Calling it again replaces the previous setup. On the server it does nothing.
144
+
145
+ | Option | Type | Default | Description |
146
+ | --- | --- | --- | --- |
147
+ | `endpoint` | `string` | — | Where batches are `POST`ed. Without one, batches only go to `exporters` and the console. |
148
+ | `sampleRate` | `number` (0–1) | `1` | Fraction of page loads that measure anything, decided once per page load. |
149
+ | `mode` | `"auto" \| "lightweight" \| "profiling"` | `"auto"` | How renders are measured. See [Modes](#modes). |
150
+ | `slowRenderMs` | `number` | `16` | Renders slower than this count as slow (one frame at 60 Hz). |
151
+ | `longTaskMs` | `number` | `50` | Shorter long tasks and long animation frames are ignored. |
152
+ | `appVersion` | `string` | — | Your app's version, for comparing releases. |
153
+ | `getRoute` | `() => string` | path with numeric segments → `:id` | The current route, ideally a template like `/orders/:id`. |
154
+ | `flushIntervalMs` | `number` | `180000` | Also send this often while the page is open. `0` sends only when it's hidden. |
155
+ | `maxPayloadBytes` | `number` | `60000` | Largest batch; bigger ones are trimmed. Capped at 65536. |
156
+ | `maxAreas` | `number` | `200` | Most area + route pairs per batch. Capped at 1000. |
157
+ | `worstCases` | `number` | `5` | Slowest renders kept per area per batch. Capped at 50. |
158
+ | `exporters` | `Exporter[]` | — | Receive measurements and batches, e.g. `createOtelExporter`. |
159
+ | `debug` | `boolean` | `true` without `endpoint` | Log each batch to the console, and internal errors as warnings. |
160
+
161
+ Returns `{ sampled, mode, flush(), destroy() }`:
162
+
163
+ - `sampled`: whether this page load measures anything.
164
+ - `mode`: the current mode; in `"auto"` it changes to `"profiling"` once React's profiling build is detected.
165
+ - `flush()`: sends a batch now and returns it, or `null` if nothing was measured.
166
+ - `destroy()`: stops observing and sending. Monitors keep rendering their children.
167
+
168
+ ### `<RenderMonitor id children />`
169
+
170
+ Measures the renders of its children as one area named `id`. Areas with the same `id` are reported together. Nested areas are reported separately, and an outer area's time includes its inner ones.
171
+
172
+ ### `useRenderMonitor(id)`
173
+
174
+ Measures the component that calls it. Render counts are exact. The duration runs from the hook to the commit, so it's an upper bound that can include components rendered after it in the same update.
175
+
176
+ ### Exporters
177
+
178
+ An exporter receives data as it's measured:
179
+
180
+ ```ts
181
+ interface Exporter {
182
+ onRender?: (event: RenderEvent) => void; // every measured render
183
+ onLongTask?: (event: LongTaskEvent) => void; // every long task / long animation frame
184
+ onFlush?: (payload: Payload) => void; // every batch
185
+ }
186
+ ```
187
+
188
+ Built in:
189
+
190
+ - `createConsoleExporter()` logs each batch as a table. It's used automatically when `debug` is on.
191
+ - `createOtelExporter({ meter, slowRenderMs? })` from `react-render-pulse/otel` records OpenTelemetry metrics: `render_pulse.render.duration` and `render_pulse.longtask.duration` (histograms, ms) and `render_pulse.render.slow` (counter), with area, route, phase and mode attributes.
192
+
193
+ ```ts
194
+ import { metrics } from "@opentelemetry/api";
195
+ import { createOtelExporter } from "react-render-pulse/otel";
196
+
197
+ initRenderPulse({ exporters: [createOtelExporter({ meter: metrics.getMeter("my-app") })] });
198
+ ```
199
+
200
+ ### Entry points
201
+
202
+ | Import | Contents |
203
+ | --- | --- |
204
+ | `react-render-pulse` | `initRenderPulse`, `RenderMonitor`, `useRenderMonitor`, `createConsoleExporter`, `HISTOGRAM_BOUNDS`, `histogramPercentile`, and all types. Marked `"use client"`. |
205
+ | `react-render-pulse/core` | Types, `HISTOGRAM_BOUNDS`, `histogramPercentile`. No React; safe in servers and Server Components. |
206
+ | `react-render-pulse/otel` | `createOtelExporter`. |
207
+
208
+ Types: `Payload`, `AreaReport`, `LongTaskReport`, `SessionInfo`, `SessionSummary`, `DeviceContext`, `WorstCase`, `RenderEvent`, `LongTaskEvent`, `Exporter`, `Mode`, `ModeOption`, `RenderPhase`, `RenderPulseOptions`, `RenderPulse`, `RenderMonitorProps`.
209
+
210
+ Full reference: [Client API](https://amalmktk.github.io/react-render-pulse/api/client).
211
+
212
+ ## Modes
213
+
214
+ | | Lightweight | Profiling |
215
+ | --- | --- | --- |
216
+ | How it measures | Two marker components around each area time its render | React's `<Profiler>` reports each commit's render time |
217
+ | Needs | Any production build | React's profiling build |
218
+ | Sees updates that start inside an area | No: wrap the component that owns the state, or use `useRenderMonitor` in it | Yes |
219
+ | Cost | ~10 µs per measured render | About the same as a bare `<Profiler>`, which is the profiling build's own cost |
220
+ | Use for | Every production user | A sampled group or staging |
221
+
222
+ `mode: "auto"` (the default) starts in lightweight mode and switches to profiling mode when React's profiling build is detected. To enable the profiling build:
223
+
224
+ ```ts
225
+ // vite.config.ts: `vite build --mode profiling`
226
+ export default defineConfig(({ mode }) => ({
227
+ plugins: [react()],
228
+ resolve: { alias: mode === "profiling" ? { "react-dom/client": "react-dom/profiling" } : {} },
229
+ }));
230
+ ```
231
+
232
+ ```bash
233
+ next build --profile # Next.js
234
+ ```
235
+
236
+ More: [Modes](https://amalmktk.github.io/react-render-pulse/guide/modes).
237
+
238
+ ## Frameworks
239
+
240
+ **Vite / any SPA:** call `initRenderPulse` in your entry file before `createRoot(...).render(...)`.
241
+
242
+ **Next.js App Router:** use `instrumentation-client.ts` at the project root, which runs before hydration. `<RenderMonitor>` can be used directly in Server Components, because the package is marked `"use client"`.
243
+
244
+ ```ts
245
+ // instrumentation-client.ts
246
+ import { initRenderPulse } from "react-render-pulse";
247
+
248
+ initRenderPulse({
249
+ endpoint: "/api/render-pulse",
250
+ getRoute: () => location.pathname.replace(/^\/orders\/[^/]+/, "/orders/[id]"),
251
+ });
252
+ ```
253
+
254
+ **Next.js Pages Router:** call it at the top level of `pages/_app.tsx`, inside `if (typeof window !== "undefined")`.
255
+
256
+ **React Router:** report route patterns with `matchRoutes`:
257
+
258
+ ```ts
259
+ getRoute: () => {
260
+ const pattern = (matchRoutes(routes, router.state.location) ?? [])
261
+ .map((m) => m.route.path ?? "")
262
+ .filter(Boolean)
263
+ .join("/")
264
+ .replace(/\/+/g, "/");
265
+ return pattern.startsWith("/") ? pattern : `/${pattern}`;
266
+ },
267
+ ```
268
+
269
+ Full setups, with working example apps: [Frameworks](https://amalmktk.github.io/react-render-pulse/guide/frameworks) · [examples/](https://github.com/Amalmktk/react-render-pulse/tree/main/examples).
270
+
271
+ ## What gets sent
272
+
273
+ One JSON object per batch (trimmed here):
274
+
275
+ ```json
276
+ {
277
+ "v": 1,
278
+ "session": { "id": "5f0c9a1e-…", "seq": 1, "mode": "lightweight", "route": "/orders", "appVersion": "2.4.0" },
279
+ "device": { "memoryGb": 4, "cores": 8, "connection": "4g", "viewport": "390x844" },
280
+ "summary": { "renders": 42, "slowRenders": 5, "longTasks": 3, "longTaskMs": 400 },
281
+ "areas": [
282
+ { "id": "orders-table", "route": "/orders", "renders": 42, "slow": 5, "p50": 6.1, "p75": 11.8, "p95": 38.2, "max": 61.2, "hist": [] }
283
+ ],
284
+ "longTasks": [{ "area": "orders-table", "route": "/orders", "count": 3, "maxMs": 212 }]
285
+ }
286
+ ```
287
+
288
+ - **Never collected:** user IDs, IP addresses, cookies, page text, input values, query strings.
289
+ - **The session id** is random per page load, held in memory only, and can't identify a user.
290
+ - **Routes** come from `location.pathname` via `getRoute`. The default only replaces numeric segments, so normalize UUIDs, usernames or slugs in `getRoute`.
291
+
292
+ Every field: [What we collect](https://amalmktk.github.io/react-render-pulse/guide/what-we-collect).
293
+
294
+ ## Performance
295
+
296
+ Measured in Chromium with a benchmark that re-renders a whole app (8 monitored areas) per update, in paired A/B blocks:
297
+
298
+ | Mode | Added per measured render | Accuracy (known 2 / 8 / 20 ms components) |
299
+ | --- | --- | --- |
300
+ | Lightweight (production build) | 0.006–0.014 ms | 2.0 / 8.0 / 20.0 ms |
301
+ | Profiling (profiling build) | ~0.07–0.08 ms, the same as a bare `<Profiler>` | 2.0 / 8.0 / 20.1 ms |
302
+
303
+ The client is 3.6 kB brotli (`initRenderPulse` + `RenderMonitor` + `useRenderMonitor`). CI fails a pull request that exceeds the overhead or size budget. Method and reproduction: [Performance](https://amalmktk.github.io/react-render-pulse/guide/performance).
304
+
305
+ ## Limitations
306
+
307
+ - Lightweight mode doesn't see updates that start below an area, such as a child's own state change. Monitor the component that owns the state, or use profiling mode for those sessions.
308
+ - `useRenderMonitor` durations are an upper bound. Use `<RenderMonitor>` around the component for its own render time.
309
+ - Long tasks are observed only where the browser supports them: Chromium for long tasks and long animation frames. Other browsers still report render timings.
310
+ - Profiling mode needs React's profiling build, which is slower than the regular build. Ship it to a sample, not everyone.
311
+
312
+ ## Related packages
313
+
314
+ - [`react-render-pulse-collector`](https://www.npmjs.com/package/react-render-pulse-collector): validates and stores batches (Postgres, JSON file, custom)
315
+ - [`react-render-pulse-viewer`](https://www.npmjs.com/package/react-render-pulse-viewer): terminal report and web viewer
316
+
317
+ ## License
318
+
319
+ MIT © Amal M
@@ -0,0 +1,27 @@
1
+ import type { AreaReport, LongTaskEvent, LongTaskReport, RenderEvent, SessionSummary } from "../types.cjs";
2
+ export interface AggregatorOptions {
3
+ /** Renders slower than this count as slow. */
4
+ slowRenderMs: number;
5
+ /** Most distinct area/route pairs kept per batch; renders for new ones past this are dropped. */
6
+ maxAreas: number;
7
+ /** How many of the slowest renders to keep per area. */
8
+ worstCases: number;
9
+ /** `performance.now()` at session start, so worst cases report time since then. */
10
+ sessionStart: number;
11
+ }
12
+ export interface AggregatedBatch {
13
+ summary: SessionSummary;
14
+ areas: AreaReport[];
15
+ longTasks: LongTaskReport[];
16
+ longFrames: LongTaskReport[];
17
+ droppedAreas: number;
18
+ }
19
+ export interface Aggregator {
20
+ addRender: (event: RenderEvent) => void;
21
+ addLongTask: (event: LongTaskEvent) => void;
22
+ /** True when nothing was recorded since the last `drain()`. */
23
+ isEmpty: () => boolean;
24
+ /** Returns everything recorded since the last call, and starts a new batch. */
25
+ drain: () => AggregatedBatch;
26
+ }
27
+ export declare function createAggregator(options: AggregatorOptions): Aggregator;
@@ -0,0 +1,28 @@
1
+ import type { AreaReport, LongTaskEvent, LongTaskReport, RenderEvent, SessionSummary } from "../types.js";
2
+ export interface AggregatorOptions {
3
+ /** Renders slower than this count as slow. */
4
+ slowRenderMs: number;
5
+ /** Most distinct area/route pairs kept per batch; renders for new ones past this are dropped. */
6
+ maxAreas: number;
7
+ /** How many of the slowest renders to keep per area. */
8
+ worstCases: number;
9
+ /** `performance.now()` at session start, so worst cases report time since then. */
10
+ sessionStart: number;
11
+ }
12
+ export interface AggregatedBatch {
13
+ summary: SessionSummary;
14
+ areas: AreaReport[];
15
+ longTasks: LongTaskReport[];
16
+ longFrames: LongTaskReport[];
17
+ droppedAreas: number;
18
+ }
19
+ export interface Aggregator {
20
+ addRender: (event: RenderEvent) => void;
21
+ addLongTask: (event: LongTaskEvent) => void;
22
+ /** True when nothing was recorded since the last `drain()`. */
23
+ isEmpty: () => boolean;
24
+ /** Returns everything recorded since the last call, and starts a new batch. */
25
+ drain: () => AggregatedBatch;
26
+ }
27
+ export declare function createAggregator(options: AggregatorOptions): Aggregator;
28
+ //# sourceMappingURL=aggregator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aggregator.d.ts","sourceRoot":"","sources":["../../src/aggregate/aggregator.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,aAAa,EAAE,cAAc,EAAE,WAAW,EAAE,cAAc,EAAa,MAAM,aAAa,CAAC;AAErH,MAAM,WAAW,iBAAiB;IAChC,8CAA8C;IAC9C,YAAY,EAAE,MAAM,CAAC;IACrB,iGAAiG;IACjG,QAAQ,EAAE,MAAM,CAAC;IACjB,wDAAwD;IACxD,UAAU,EAAE,MAAM,CAAC;IACnB,mFAAmF;IACnF,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,cAAc,CAAC;IACxB,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,SAAS,EAAE,cAAc,EAAE,CAAC;IAC5B,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;IACxC,WAAW,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAC5C,+DAA+D;IAC/D,OAAO,EAAE,MAAM,OAAO,CAAC;IACvB,+EAA+E;IAC/E,KAAK,EAAE,MAAM,eAAe,CAAC;CAC9B;AAyBD,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,iBAAiB,GAAG,UAAU,CAoGvE"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Upper bounds (ms, inclusive) of the render-duration histogram buckets, plus
3
+ * an implicit overflow bucket. Part of payload v1: the collector merges
4
+ * histograms by index, so changing these needs a new payload version.
5
+ */
6
+ export declare const HISTOGRAM_BOUNDS: readonly [1, 2, 4, 8, 12, 16, 24, 33, 50, 75, 100, 150, 250, 500, 1000];
7
+ export declare function createHistogram(): number[];
8
+ export declare function bucketIndex(ms: number): number;
9
+ /**
10
+ * Estimates a percentile (0–100) from bucket counts, interpolating linearly
11
+ * inside the bucket. The observed `min` and `max` narrow the bucket's range
12
+ * (every value lies between them): without them, durations clustered at a
13
+ * bucket edge, like many renders of exactly 8 ms, would be estimated from
14
+ * the bucket's middle instead.
15
+ */
16
+ export declare function histogramPercentile(hist: readonly number[], p: number, max: number, min?: number): number;
17
+ /** Rounds to 0.1 ms: finer precision isn't meaningful and costs payload bytes. */
18
+ export declare function round(ms: number): number;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Upper bounds (ms, inclusive) of the render-duration histogram buckets, plus
3
+ * an implicit overflow bucket. Part of payload v1: the collector merges
4
+ * histograms by index, so changing these needs a new payload version.
5
+ */
6
+ export declare const HISTOGRAM_BOUNDS: readonly [1, 2, 4, 8, 12, 16, 24, 33, 50, 75, 100, 150, 250, 500, 1000];
7
+ export declare function createHistogram(): number[];
8
+ export declare function bucketIndex(ms: number): number;
9
+ /**
10
+ * Estimates a percentile (0–100) from bucket counts, interpolating linearly
11
+ * inside the bucket. The observed `min` and `max` narrow the bucket's range
12
+ * (every value lies between them): without them, durations clustered at a
13
+ * bucket edge, like many renders of exactly 8 ms, would be estimated from
14
+ * the bucket's middle instead.
15
+ */
16
+ export declare function histogramPercentile(hist: readonly number[], p: number, max: number, min?: number): number;
17
+ /** Rounds to 0.1 ms: finer precision isn't meaningful and costs payload bytes. */
18
+ export declare function round(ms: number): number;
19
+ //# sourceMappingURL=histogram.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"histogram.d.ts","sourceRoot":"","sources":["../../src/aggregate/histogram.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,YAAI,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,CAAU,CAAC;AAExG,wBAAgB,eAAe,IAAI,MAAM,EAAE,CAE1C;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAK9C;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,SAAI,GAAG,MAAM,CAsBpG;AAED,kFAAkF;AAClF,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAExC"}
@@ -0,0 +1,19 @@
1
+ import { type ReactNode } from "react";
2
+ export interface RenderMonitorProps {
3
+ /** Names this area in reports, e.g. `"orders-table"`. Areas with the same id are reported together. */
4
+ id: string;
5
+ children?: ReactNode;
6
+ }
7
+ /**
8
+ * Measures the renders of its children and reports them under `id`.
9
+ *
10
+ * The tree it renders is the same in every mode and whether or not the
11
+ * session is sampled (a `<Profiler>` around two empty marker components), so
12
+ * switching modes or calling `initRenderPulse` late never remounts children.
13
+ * - Profiling mode: React's `<Profiler>` reports each commit's actual render time.
14
+ * - Lightweight mode: the time between the start and end markers rendering,
15
+ * which brackets the children's render. Updates that start below this area
16
+ * (a child's own state change) don't re-render the markers, so they aren't
17
+ * seen; profiling mode catches those.
18
+ */
19
+ export declare function RenderMonitor({ id, children }: RenderMonitorProps): ReactNode;
@@ -0,0 +1,20 @@
1
+ import { type ReactNode } from "react";
2
+ export interface RenderMonitorProps {
3
+ /** Names this area in reports, e.g. `"orders-table"`. Areas with the same id are reported together. */
4
+ id: string;
5
+ children?: ReactNode;
6
+ }
7
+ /**
8
+ * Measures the renders of its children and reports them under `id`.
9
+ *
10
+ * The tree it renders is the same in every mode and whether or not the
11
+ * session is sampled (a `<Profiler>` around two empty marker components), so
12
+ * switching modes or calling `initRenderPulse` late never remounts children.
13
+ * - Profiling mode: React's `<Profiler>` reports each commit's actual render time.
14
+ * - Lightweight mode: the time between the start and end markers rendering,
15
+ * which brackets the children's render. Updates that start below this area
16
+ * (a child's own state change) don't re-render the markers, so they aren't
17
+ * seen; profiling mode catches those.
18
+ */
19
+ export declare function RenderMonitor({ id, children }: RenderMonitorProps): ReactNode;
20
+ //# sourceMappingURL=RenderMonitor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RenderMonitor.d.ts","sourceRoot":"","sources":["../../src/components/RenderMonitor.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAgE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAKrG,MAAM,WAAW,kBAAkB;IACjC,uGAAuG;IACvG,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AAoBD;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,kBAAkB,GAAG,SAAS,CAyB7E"}
@@ -0,0 +1,2 @@
1
+ import type { DeviceContext } from "../types.cjs";
2
+ export declare function getDeviceContext(): DeviceContext;
@@ -0,0 +1,3 @@
1
+ import type { DeviceContext } from "../types.js";
2
+ export declare function getDeviceContext(): DeviceContext;
3
+ //# sourceMappingURL=device.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device.d.ts","sourceRoot":"","sources":["../../src/context/device.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AASjD,wBAAgB,gBAAgB,IAAI,aAAa,CAShD"}
@@ -0,0 +1,7 @@
1
+ /** Default route reader: the path with numeric segments replaced, so `/orders/42` and `/orders/7` group together. */
2
+ export declare function defaultGetRoute(): string;
3
+ /**
4
+ * Wraps a route reader so it only runs when the URL changes, since it's
5
+ * called on every measured render.
6
+ */
7
+ export declare function createRouteReader(getRoute: () => string): () => string;
@@ -0,0 +1,8 @@
1
+ /** Default route reader: the path with numeric segments replaced, so `/orders/42` and `/orders/7` group together. */
2
+ export declare function defaultGetRoute(): string;
3
+ /**
4
+ * Wraps a route reader so it only runs when the URL changes, since it's
5
+ * called on every measured render.
6
+ */
7
+ export declare function createRouteReader(getRoute: () => string): () => string;
8
+ //# sourceMappingURL=route.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"route.d.ts","sourceRoot":"","sources":["../../src/context/route.ts"],"names":[],"mappings":"AAAA,qHAAqH;AACrH,wBAAgB,eAAe,IAAI,MAAM,CAGxC;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,MAAM,GAAG,MAAM,MAAM,CAYtE"}
package/dist/core.cjs ADDED
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/core.ts
21
+ var core_exports = {};
22
+ __export(core_exports, {
23
+ HISTOGRAM_BOUNDS: () => HISTOGRAM_BOUNDS,
24
+ histogramPercentile: () => histogramPercentile
25
+ });
26
+ module.exports = __toCommonJS(core_exports);
27
+
28
+ // src/aggregate/histogram.ts
29
+ var HISTOGRAM_BOUNDS = [1, 2, 4, 8, 12, 16, 24, 33, 50, 75, 100, 150, 250, 500, 1e3];
30
+ function histogramPercentile(hist, p, max, min = 0) {
31
+ const total = hist.reduce((sum, n) => sum + n, 0);
32
+ if (total === 0) return 0;
33
+ const target = Math.max(p / 100 * total, Number.MIN_VALUE);
34
+ let seen = 0;
35
+ for (let i = 0; i < hist.length; i++) {
36
+ const count = hist[i] ?? 0;
37
+ if (count === 0) continue;
38
+ if (seen + count >= target) {
39
+ const upper = Math.min(HISTOGRAM_BOUNDS[i] ?? max, max);
40
+ const lower = Math.min(Math.max(i === 0 ? 0 : HISTOGRAM_BOUNDS[i - 1] ?? 0, min), upper);
41
+ const fraction = (target - seen) / count;
42
+ return round(Math.min(lower + (upper - lower) * fraction, max));
43
+ }
44
+ seen += count;
45
+ }
46
+ return round(max);
47
+ }
48
+ function round(ms) {
49
+ return Math.round(ms * 10) / 10;
50
+ }
51
+ // Annotate the CommonJS export names for ESM import in node:
52
+ 0 && (module.exports = {
53
+ HISTOGRAM_BOUNDS,
54
+ histogramPercentile
55
+ });
56
+ //# sourceMappingURL=core.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core.ts","../src/aggregate/histogram.ts"],"sourcesContent":["// Framework-agnostic entry: no React imports, so it's safe in Server Components,\n// route handlers, and plain Node. Everything here is also exported from the main entry.\nexport type {\n AreaReport,\n DeviceContext,\n Exporter,\n LongTaskEvent,\n LongTaskKind,\n LongTaskReport,\n Mode,\n ModeOption,\n Payload,\n RenderEvent,\n RenderPhase,\n SessionInfo,\n SessionSummary,\n WorstCase,\n} from \"./types.js\";\nexport { HISTOGRAM_BOUNDS, histogramPercentile } from \"./aggregate/histogram.js\";\n","/**\n * Upper bounds (ms, inclusive) of the render-duration histogram buckets, plus\n * an implicit overflow bucket. Part of payload v1: the collector merges\n * histograms by index, so changing these needs a new payload version.\n */\nexport const HISTOGRAM_BOUNDS = [1, 2, 4, 8, 12, 16, 24, 33, 50, 75, 100, 150, 250, 500, 1000] as const;\n\nexport function createHistogram(): number[] {\n return new Array<number>(HISTOGRAM_BOUNDS.length + 1).fill(0);\n}\n\nexport function bucketIndex(ms: number): number {\n for (let i = 0; i < HISTOGRAM_BOUNDS.length; i++) {\n if (ms <= (HISTOGRAM_BOUNDS[i] ?? 0)) return i;\n }\n return HISTOGRAM_BOUNDS.length;\n}\n\n/**\n * Estimates a percentile (0–100) from bucket counts, interpolating linearly\n * inside the bucket. The observed `min` and `max` narrow the bucket's range\n * (every value lies between them): without them, durations clustered at a\n * bucket edge, like many renders of exactly 8 ms, would be estimated from\n * the bucket's middle instead.\n */\nexport function histogramPercentile(hist: readonly number[], p: number, max: number, min = 0): number {\n const total = hist.reduce((sum, n) => sum + n, 0);\n if (total === 0) return 0;\n\n const target = Math.max((p / 100) * total, Number.MIN_VALUE);\n let seen = 0;\n\n for (let i = 0; i < hist.length; i++) {\n const count = hist[i] ?? 0;\n if (count === 0) continue;\n\n if (seen + count >= target) {\n const upper = Math.min(HISTOGRAM_BOUNDS[i] ?? max, max);\n const lower = Math.min(Math.max(i === 0 ? 0 : (HISTOGRAM_BOUNDS[i - 1] ?? 0), min), upper);\n const fraction = (target - seen) / count;\n return round(Math.min(lower + (upper - lower) * fraction, max));\n }\n\n seen += count;\n }\n\n return round(max);\n}\n\n/** Rounds to 0.1 ms: finer precision isn't meaningful and costs payload bytes. */\nexport function round(ms: number): number {\n return Math.round(ms * 10) / 10;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACKO,IAAM,mBAAmB,CAAC,GAAG,GAAG,GAAG,GAAG,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,KAAK,KAAK,KAAK,KAAK,GAAI;AAoBtF,SAAS,oBAAoB,MAAyB,GAAW,KAAa,MAAM,GAAW;AACpG,QAAM,QAAQ,KAAK,OAAO,CAAC,KAAK,MAAM,MAAM,GAAG,CAAC;AAChD,MAAI,UAAU,EAAG,QAAO;AAExB,QAAM,SAAS,KAAK,IAAK,IAAI,MAAO,OAAO,OAAO,SAAS;AAC3D,MAAI,OAAO;AAEX,WAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;AACpC,UAAM,QAAQ,KAAK,CAAC,KAAK;AACzB,QAAI,UAAU,EAAG;AAEjB,QAAI,OAAO,SAAS,QAAQ;AAC1B,YAAM,QAAQ,KAAK,IAAI,iBAAiB,CAAC,KAAK,KAAK,GAAG;AACtD,YAAM,QAAQ,KAAK,IAAI,KAAK,IAAI,MAAM,IAAI,IAAK,iBAAiB,IAAI,CAAC,KAAK,GAAI,GAAG,GAAG,KAAK;AACzF,YAAM,YAAY,SAAS,QAAQ;AACnC,aAAO,MAAM,KAAK,IAAI,SAAS,QAAQ,SAAS,UAAU,GAAG,CAAC;AAAA,IAChE;AAEA,YAAQ;AAAA,EACV;AAEA,SAAO,MAAM,GAAG;AAClB;AAGO,SAAS,MAAM,IAAoB;AACxC,SAAO,KAAK,MAAM,KAAK,EAAE,IAAI;AAC/B;","names":[]}
@@ -0,0 +1,2 @@
1
+ export type { AreaReport, DeviceContext, Exporter, LongTaskEvent, LongTaskKind, LongTaskReport, Mode, ModeOption, Payload, RenderEvent, RenderPhase, SessionInfo, SessionSummary, WorstCase, } from "./types.cjs";
2
+ export { HISTOGRAM_BOUNDS, histogramPercentile } from "./aggregate/histogram.cjs";
package/dist/core.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export type { AreaReport, DeviceContext, Exporter, LongTaskEvent, LongTaskKind, LongTaskReport, Mode, ModeOption, Payload, RenderEvent, RenderPhase, SessionInfo, SessionSummary, WorstCase, } from "./types.js";
2
+ export { HISTOGRAM_BOUNDS, histogramPercentile } from "./aggregate/histogram.js";
3
+ //# sourceMappingURL=core.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"AAEA,YAAY,EACV,UAAU,EACV,aAAa,EACb,QAAQ,EACR,aAAa,EACb,YAAY,EACZ,cAAc,EACd,IAAI,EACJ,UAAU,EACV,OAAO,EACP,WAAW,EACX,WAAW,EACX,WAAW,EACX,cAAc,EACd,SAAS,GACV,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC"}
package/dist/core.js ADDED
@@ -0,0 +1,28 @@
1
+ // src/aggregate/histogram.ts
2
+ var HISTOGRAM_BOUNDS = [1, 2, 4, 8, 12, 16, 24, 33, 50, 75, 100, 150, 250, 500, 1e3];
3
+ function histogramPercentile(hist, p, max, min = 0) {
4
+ const total = hist.reduce((sum, n) => sum + n, 0);
5
+ if (total === 0) return 0;
6
+ const target = Math.max(p / 100 * total, Number.MIN_VALUE);
7
+ let seen = 0;
8
+ for (let i = 0; i < hist.length; i++) {
9
+ const count = hist[i] ?? 0;
10
+ if (count === 0) continue;
11
+ if (seen + count >= target) {
12
+ const upper = Math.min(HISTOGRAM_BOUNDS[i] ?? max, max);
13
+ const lower = Math.min(Math.max(i === 0 ? 0 : HISTOGRAM_BOUNDS[i - 1] ?? 0, min), upper);
14
+ const fraction = (target - seen) / count;
15
+ return round(Math.min(lower + (upper - lower) * fraction, max));
16
+ }
17
+ seen += count;
18
+ }
19
+ return round(max);
20
+ }
21
+ function round(ms) {
22
+ return Math.round(ms * 10) / 10;
23
+ }
24
+ export {
25
+ HISTOGRAM_BOUNDS,
26
+ histogramPercentile
27
+ };
28
+ //# sourceMappingURL=core.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/aggregate/histogram.ts"],"sourcesContent":["/**\n * Upper bounds (ms, inclusive) of the render-duration histogram buckets, plus\n * an implicit overflow bucket. Part of payload v1: the collector merges\n * histograms by index, so changing these needs a new payload version.\n */\nexport const HISTOGRAM_BOUNDS = [1, 2, 4, 8, 12, 16, 24, 33, 50, 75, 100, 150, 250, 500, 1000] as const;\n\nexport function createHistogram(): number[] {\n return new Array<number>(HISTOGRAM_BOUNDS.length + 1).fill(0);\n}\n\nexport function bucketIndex(ms: number): number {\n for (let i = 0; i < HISTOGRAM_BOUNDS.length; i++) {\n if (ms <= (HISTOGRAM_BOUNDS[i] ?? 0)) return i;\n }\n return HISTOGRAM_BOUNDS.length;\n}\n\n/**\n * Estimates a percentile (0–100) from bucket counts, interpolating linearly\n * inside the bucket. The observed `min` and `max` narrow the bucket's range\n * (every value lies between them): without them, durations clustered at a\n * bucket edge, like many renders of exactly 8 ms, would be estimated from\n * the bucket's middle instead.\n */\nexport function histogramPercentile(hist: readonly number[], p: number, max: number, min = 0): number {\n const total = hist.reduce((sum, n) => sum + n, 0);\n if (total === 0) return 0;\n\n const target = Math.max((p / 100) * total, Number.MIN_VALUE);\n let seen = 0;\n\n for (let i = 0; i < hist.length; i++) {\n const count = hist[i] ?? 0;\n if (count === 0) continue;\n\n if (seen + count >= target) {\n const upper = Math.min(HISTOGRAM_BOUNDS[i] ?? max, max);\n const lower = Math.min(Math.max(i === 0 ? 0 : (HISTOGRAM_BOUNDS[i - 1] ?? 0), min), upper);\n const fraction = (target - seen) / count;\n return round(Math.min(lower + (upper - lower) * fraction, max));\n }\n\n seen += count;\n }\n\n return round(max);\n}\n\n/** Rounds to 0.1 ms: finer precision isn't meaningful and costs payload bytes. */\nexport function round(ms: number): number {\n return Math.round(ms * 10) / 10;\n}\n"],"mappings":";AAKO,IAAM,mBAAmB,CAAC,GAAG,GAAG,GAAG,GAAG,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,KAAK,KAAK,KAAK,KAAK,GAAI;AAoBtF,SAAS,oBAAoB,MAAyB,GAAW,KAAa,MAAM,GAAW;AACpG,QAAM,QAAQ,KAAK,OAAO,CAAC,KAAK,MAAM,MAAM,GAAG,CAAC;AAChD,MAAI,UAAU,EAAG,QAAO;AAExB,QAAM,SAAS,KAAK,IAAK,IAAI,MAAO,OAAO,OAAO,SAAS;AAC3D,MAAI,OAAO;AAEX,WAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;AACpC,UAAM,QAAQ,KAAK,CAAC,KAAK;AACzB,QAAI,UAAU,EAAG;AAEjB,QAAI,OAAO,SAAS,QAAQ;AAC1B,YAAM,QAAQ,KAAK,IAAI,iBAAiB,CAAC,KAAK,KAAK,GAAG;AACtD,YAAM,QAAQ,KAAK,IAAI,KAAK,IAAI,MAAM,IAAI,IAAK,iBAAiB,IAAI,CAAC,KAAK,GAAI,GAAG,GAAG,KAAK;AACzF,YAAM,YAAY,SAAS,QAAQ;AACnC,aAAO,MAAM,KAAK,IAAI,SAAS,QAAQ,SAAS,UAAU,GAAG,CAAC;AAAA,IAChE;AAEA,YAAQ;AAAA,EACV;AAEA,SAAO,MAAM,GAAG;AAClB;AAGO,SAAS,MAAM,IAAoB;AACxC,SAAO,KAAK,MAAM,KAAK,EAAE,IAAI;AAC/B;","names":[]}