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.
- package/LICENSE +21 -0
- package/README.md +318 -2
- package/dist/aggregate/aggregator.d.cts +27 -0
- package/dist/aggregate/aggregator.d.ts +28 -0
- package/dist/aggregate/aggregator.d.ts.map +1 -0
- package/dist/aggregate/histogram.d.cts +18 -0
- package/dist/aggregate/histogram.d.ts +19 -0
- package/dist/aggregate/histogram.d.ts.map +1 -0
- package/dist/components/RenderMonitor.d.cts +19 -0
- package/dist/components/RenderMonitor.d.ts +20 -0
- package/dist/components/RenderMonitor.d.ts.map +1 -0
- package/dist/context/device.d.cts +2 -0
- package/dist/context/device.d.ts +3 -0
- package/dist/context/device.d.ts.map +1 -0
- package/dist/context/route.d.cts +7 -0
- package/dist/context/route.d.ts +8 -0
- package/dist/context/route.d.ts.map +1 -0
- package/dist/core.cjs +56 -0
- package/dist/core.cjs.map +1 -0
- package/dist/core.d.cts +2 -0
- package/dist/core.d.ts +3 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +28 -0
- package/dist/core.js.map +1 -0
- package/dist/export/console.d.cts +3 -0
- package/dist/export/console.d.ts +4 -0
- package/dist/export/console.d.ts.map +1 -0
- package/dist/export/otel.d.cts +34 -0
- package/dist/export/otel.d.ts +35 -0
- package/dist/export/otel.d.ts.map +1 -0
- package/dist/hooks/useRenderMonitor.d.cts +9 -0
- package/dist/hooks/useRenderMonitor.d.ts +10 -0
- package/dist/hooks/useRenderMonitor.d.ts.map +1 -0
- package/dist/index.cjs +634 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +602 -0
- package/dist/index.js.map +1 -0
- package/dist/modes/detect.d.cts +16 -0
- package/dist/modes/detect.d.ts +17 -0
- package/dist/modes/detect.d.ts.map +1 -0
- package/dist/observe/longtasks.d.cts +12 -0
- package/dist/observe/longtasks.d.ts +13 -0
- package/dist/observe/longtasks.d.ts.map +1 -0
- package/dist/observe/renderWindows.d.cts +17 -0
- package/dist/observe/renderWindows.d.ts +18 -0
- package/dist/observe/renderWindows.d.ts.map +1 -0
- package/dist/otel.cjs +65 -0
- package/dist/otel.cjs.map +1 -0
- package/dist/otel.d.cts +2 -0
- package/dist/otel.d.ts +3 -0
- package/dist/otel.d.ts.map +1 -0
- package/dist/otel.js +38 -0
- package/dist/otel.js.map +1 -0
- package/dist/runtime/guard.d.cts +7 -0
- package/dist/runtime/guard.d.ts +8 -0
- package/dist/runtime/guard.d.ts.map +1 -0
- package/dist/runtime/initRenderPulse.d.cts +44 -0
- package/dist/runtime/initRenderPulse.d.ts +45 -0
- package/dist/runtime/initRenderPulse.d.ts.map +1 -0
- package/dist/runtime/runtime.d.cts +13 -0
- package/dist/runtime/runtime.d.ts +14 -0
- package/dist/runtime/runtime.d.ts.map +1 -0
- package/dist/runtime/useIsomorphicLayoutEffect.d.cts +2 -0
- package/dist/runtime/useIsomorphicLayoutEffect.d.ts +3 -0
- package/dist/runtime/useIsomorphicLayoutEffect.d.ts.map +1 -0
- package/dist/sample/sample.d.cts +7 -0
- package/dist/sample/sample.d.ts +8 -0
- package/dist/sample/sample.d.ts.map +1 -0
- package/dist/transport/payload.d.cts +8 -0
- package/dist/transport/payload.d.ts +9 -0
- package/dist/transport/payload.d.ts.map +1 -0
- package/dist/transport/transport.d.cts +18 -0
- package/dist/transport/transport.d.ts +19 -0
- package/dist/transport/transport.d.ts.map +1 -0
- package/dist/types.d.cts +120 -0
- package/dist/types.d.ts +121 -0
- package/dist/types.d.ts.map +1 -0
- 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
|
-
#
|
|
1
|
+
# react-render-pulse
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/react-render-pulse)
|
|
4
|
+
[](https://github.com/Amalmktk/react-render-pulse/actions/workflows/ci.yml)
|
|
5
|
+
[](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 @@
|
|
|
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":[]}
|
package/dist/core.d.cts
ADDED
|
@@ -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
|
package/dist/core.js.map
ADDED
|
@@ -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":[]}
|