measure-fn 3.2.0 → 3.3.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 +79 -2
- package/SKILL.md +178 -60
- package/bench.ts +54 -0
- package/bun.lock +26 -25
- package/example.ts +57 -1
- package/index.test.ts +65 -4
- package/index.ts +9 -2
- package/package.json +1 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 7flash
|
|
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
|
@@ -162,6 +162,31 @@ await api.measure('GET /users', async () => {
|
|
|
162
162
|
// → [api:a] ✓ GET /users 45ms → [...]
|
|
163
163
|
```
|
|
164
164
|
|
|
165
|
+
### Bun.serve — handling Response
|
|
166
|
+
|
|
167
|
+
`measure()` returns `null` on error instead of throwing. In `Bun.serve`, the fetch handler **must** return a `Response` — returning `null` crashes. Two solutions:
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
// ✅ measure.assert — throws on error, use with Bun.serve error handler
|
|
171
|
+
Bun.serve({
|
|
172
|
+
fetch: (req) => measure.assert('Handle', async () => {
|
|
173
|
+
return new Response('ok');
|
|
174
|
+
}),
|
|
175
|
+
error: () => new Response('Internal Server Error', { status: 500 }),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ✅ Nullish coalescing — graceful 500 fallback
|
|
179
|
+
Bun.serve({
|
|
180
|
+
fetch: async (req) => {
|
|
181
|
+
return (await measure('Handle', async () => {
|
|
182
|
+
return new Response('ok');
|
|
183
|
+
})) ?? new Response('Internal Server Error', { status: 500 });
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
> **Why not plain `measure()`?** On error it returns `null`, not a `Response`. This is by design — `measure` never throws (except `.assert()`).
|
|
189
|
+
|
|
165
190
|
### `configure(opts)` — runtime config
|
|
166
191
|
|
|
167
192
|
```typescript
|
|
@@ -193,9 +218,61 @@ formatDuration(91234); // "1m 31s"
|
|
|
193
218
|
resetCounter(); // reset ID counter for tests
|
|
194
219
|
```
|
|
195
220
|
|
|
196
|
-
##
|
|
221
|
+
## Why `measure` Never Throws
|
|
222
|
+
|
|
223
|
+
This is a deliberate design choice, not a shortcut.
|
|
224
|
+
|
|
225
|
+
**Measure is observability, not error handling.** It answers "what happened, how long did it take, did it fail?" — not "how should I recover from this specific error?" That's your job, inside the callback.
|
|
226
|
+
|
|
227
|
+
**The Go inspiration (and where it differs):** Go's `result, err := doSomething()` treats errors as values. `measure` takes the same philosophy — errors don't crash your pipeline — but it intentionally discards the error *to the caller*. Why? Because every error is **already logged** with `✗`, timing, stack trace, and cause chain. There are no silent failures. The error is visible, just not returned.
|
|
197
228
|
|
|
198
|
-
**
|
|
229
|
+
**Handle recoverable errors inside, let measure catch the rest:**
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
const user = await measure('Fetch user', async () => {
|
|
233
|
+
try {
|
|
234
|
+
return await fetchUser(1);
|
|
235
|
+
} catch (e) {
|
|
236
|
+
if (e instanceof NetworkError) {
|
|
237
|
+
return await fetchFromCache(1); // recover from known error
|
|
238
|
+
}
|
|
239
|
+
throw e; // unexpected — measure catches, logs ✗, returns null
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
This is the correct mental model: **try/catch inside for errors you know how to handle**, `null` outside for everything unexpected. You don't wrap measure in try/catch — you put recovery logic *within* it.
|
|
245
|
+
|
|
246
|
+
**When you need the result to be non-null:**
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
// .assert() — re-throws, guarantees non-null
|
|
250
|
+
const user = await measure.assert('Get user', () => fetchUser(1));
|
|
251
|
+
|
|
252
|
+
// ?? — graceful fallback
|
|
253
|
+
const user = (await measure('Get user', () => fetchUser(1))) ?? defaultUser;
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
**Summary:**
|
|
257
|
+
|
|
258
|
+
| Method | On error | Use when |
|
|
259
|
+
|--------|----------|----------|
|
|
260
|
+
| `measure()` | logs `✗`, returns `null` | Default — pipeline keeps running |
|
|
261
|
+
| `try/catch` inside | you handle it | Recoverable errors (network, retries) |
|
|
262
|
+
| `measure.assert()` | logs `✗`, then throws (`.cause` = original error) | Must have non-null (Bun.serve, etc.) |
|
|
263
|
+
| `?? fallback` | returns fallback | Graceful degradation |
|
|
264
|
+
|
|
265
|
+
**Watch out: layered architectures.** When you have `measure → handler → handler's own try/catch`, a `null` return is ambiguous — did the handler catch the error and return a valid fallback, or did measure catch it and swallow it? If your handler has its own error handling, use `measure.assert()` instead of `measure()` so measure stays out of the error path:
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
// ✗ Ambiguous: did handler or measure catch the error?
|
|
269
|
+
const response = await measure('Handle', () => handler(req)); // null = ???
|
|
270
|
+
|
|
271
|
+
// ✓ Clear: measure observes, handler handles its own errors
|
|
272
|
+
const response = await measure.assert('Handle', () => handler(req));
|
|
273
|
+
// handler's try/catch runs first → returns error Response
|
|
274
|
+
// measure only catches if handler ITSELF throws (unexpected)
|
|
275
|
+
```
|
|
199
276
|
|
|
200
277
|
## Types
|
|
201
278
|
|
package/SKILL.md
CHANGED
|
@@ -3,111 +3,229 @@ name: measure-fn
|
|
|
3
3
|
description: Zero-dependency function performance measurement with hierarchical logging. Use this to structure ALL programs as measured, observable pipelines.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# measure-fn —
|
|
6
|
+
# measure-fn — Write Observable Code
|
|
7
7
|
|
|
8
|
-
**Every
|
|
8
|
+
**Every function you write should be measured.** Don't write blind code — wrap operations in `measure` so every success, failure, and timing is automatically visible. This is the default way to structure programs.
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## Install
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
13
|
bun add measure-fn
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## The Rule
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
> If a function does I/O, computation, or anything non-trivial — **wrap it in `measure`**.
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
// ✗ BAD — blind, silent, no timing, no error visibility
|
|
22
|
+
const users = await fetchUsers();
|
|
23
|
+
const config = loadConfig();
|
|
24
|
+
|
|
25
|
+
// ✓ GOOD — observable, timed, errors auto-logged
|
|
26
|
+
const users = await measure('Fetch users', () => fetchUsers());
|
|
27
|
+
const config = measureSync('Load config', () => loadConfig());
|
|
23
28
|
```
|
|
24
29
|
|
|
25
|
-
|
|
30
|
+
## Patterns
|
|
26
31
|
|
|
27
|
-
|
|
32
|
+
### 1. Every entry point is a measured pipeline
|
|
28
33
|
|
|
29
34
|
```typescript
|
|
30
|
-
import { measure, measureSync
|
|
35
|
+
import { measure, measureSync } from 'measure-fn';
|
|
36
|
+
|
|
37
|
+
async function main() {
|
|
38
|
+
const config = measureSync('Load config', () => readConfig());
|
|
39
|
+
const db = await measure('Connect DB', () => connectDatabase(config));
|
|
40
|
+
const users = await measure('Fetch users', () => db.query('SELECT * FROM users'));
|
|
41
|
+
await measure('Send emails', () => sendEmails(users));
|
|
42
|
+
}
|
|
43
|
+
```
|
|
31
44
|
|
|
32
|
-
|
|
33
|
-
|
|
45
|
+
Output:
|
|
46
|
+
```
|
|
47
|
+
[a] ✓ Load config 0.12ms → {"env":"prod"}
|
|
48
|
+
[b] ... Connect DB
|
|
49
|
+
[b] ✓ Connect DB 45ms → [DB]
|
|
50
|
+
[c] ... Fetch users
|
|
51
|
+
[c] ✓ Fetch users 23ms → [{"id":1},{"id":2}]
|
|
52
|
+
[d] ... Send emails
|
|
53
|
+
[d] ✓ Send emails 102ms
|
|
54
|
+
```
|
|
34
55
|
|
|
35
|
-
|
|
36
|
-
const config = measureSync('Load config', () => loadConfig());
|
|
56
|
+
### 2. Nested operations use the child measure
|
|
37
57
|
|
|
38
|
-
|
|
58
|
+
```typescript
|
|
39
59
|
await measure('Pipeline', async (m) => {
|
|
40
|
-
await
|
|
41
|
-
|
|
42
|
-
|
|
60
|
+
const raw = await m('Fetch', () => fetchData());
|
|
61
|
+
const parsed = m('Parse', () => parseData(raw));
|
|
62
|
+
await m('Save', () => saveResult(parsed));
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 3. Parallel work with `Promise.all`
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
await measure('Load all', async (m) => {
|
|
70
|
+
const [users, posts, settings] = await Promise.all([
|
|
71
|
+
m('Users', () => fetchUsers()),
|
|
72
|
+
m('Posts', () => fetchPosts()),
|
|
73
|
+
m('Settings', () => fetchSettings()),
|
|
43
74
|
]);
|
|
75
|
+
return { users, posts, settings };
|
|
44
76
|
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 4. Wrap reusable functions once
|
|
45
80
|
|
|
46
|
-
|
|
81
|
+
```typescript
|
|
47
82
|
const getUser = measure.wrap('Get user', fetchUser);
|
|
48
|
-
|
|
49
|
-
await getUser(
|
|
83
|
+
// Every call is now measured automatically
|
|
84
|
+
await getUser(1); // → [a] ✓ Get user 82ms → {...}
|
|
85
|
+
await getUser(2); // → [b] ✓ Get user 75ms → {...}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 5. Process arrays with progress
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
await measure.batch('Process users', userIds, async (id) => {
|
|
92
|
+
return await processUser(id);
|
|
93
|
+
}, { every: 100 });
|
|
94
|
+
// → [a] ... Process users (500 items)
|
|
95
|
+
// → [a] = 100/500 (1.2s, 83/s)
|
|
96
|
+
// → [a] ✓ Process users (500 items) 5.3s → "500/500 ok"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 6. Retry flaky operations
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
const result = await measure.retry('External API', {
|
|
103
|
+
attempts: 3, delay: 1000, backoff: 2
|
|
104
|
+
}, () => callExternalService());
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 7. Budget warnings for slow ops
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
await measure({ label: 'DB query', budget: 100 }, () => heavyQuery());
|
|
111
|
+
// → [a] ✓ DB query 245ms → [...] ⚠ OVER BUDGET (100ms)
|
|
112
|
+
```
|
|
50
113
|
|
|
51
|
-
|
|
52
|
-
await measure.batch('Process', items, async (item) => transform(item), { every: 100 });
|
|
114
|
+
### 8. Assert non-null results
|
|
53
115
|
|
|
54
|
-
|
|
55
|
-
|
|
116
|
+
```typescript
|
|
117
|
+
// Guaranteed non-null — throws if the function returns null/undefined
|
|
118
|
+
const user = await measure.assert('Get user', () => findUser(id));
|
|
119
|
+
```
|
|
56
120
|
|
|
57
|
-
|
|
58
|
-
const user = await measure.assert('Get user', () => fetchUser(1));
|
|
121
|
+
### 9. Scoped instances for subsystems
|
|
59
122
|
|
|
60
|
-
|
|
61
|
-
|
|
123
|
+
```typescript
|
|
124
|
+
const api = createMeasure('api');
|
|
125
|
+
const db = createMeasure('db');
|
|
62
126
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
127
|
+
await api.measure('GET /users', async () => {
|
|
128
|
+
return await db.measure('SELECT', () => query('SELECT * FROM users'));
|
|
129
|
+
});
|
|
130
|
+
// → [api:a] ... GET /users
|
|
131
|
+
// → [db:a] ✓ SELECT 44ms → [...]
|
|
132
|
+
// → [api:a] ✓ GET /users 45ms → [...]
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### 10. Annotations for checkpoints
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
await measure('Server ready'); // → [a] = Server ready
|
|
139
|
+
measureSync('Config loaded'); // → [b] = Config loaded
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### 11. Bun.serve — always use `measure.assert` or fallback
|
|
143
|
+
|
|
144
|
+
`measure()` returns `null` on error. In a fetch handler, you **must** return a `Response`:
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
// ✅ measure.assert — throws on error, pair with Bun.serve error handler
|
|
148
|
+
Bun.serve({
|
|
149
|
+
fetch: (req) => measure.assert('Handle', async () => {
|
|
150
|
+
return new Response('ok');
|
|
151
|
+
}),
|
|
152
|
+
error: () => new Response('Internal Server Error', { status: 500 }),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ✅ Nullish coalescing fallback
|
|
156
|
+
Bun.serve({
|
|
157
|
+
fetch: async (req) => {
|
|
158
|
+
return (await measure('Handle', async () => {
|
|
159
|
+
return new Response('ok');
|
|
160
|
+
})) ?? new Response('Internal Server Error', { status: 500 });
|
|
161
|
+
},
|
|
162
|
+
});
|
|
66
163
|
```
|
|
67
164
|
|
|
68
165
|
## Configuration
|
|
69
166
|
|
|
70
167
|
```typescript
|
|
168
|
+
import { configure } from 'measure-fn';
|
|
169
|
+
|
|
71
170
|
configure({
|
|
72
|
-
silent: true, // suppress output
|
|
171
|
+
silent: true, // suppress output (for benchmarks)
|
|
73
172
|
timestamps: true, // [HH:MM:SS.mmm] prefix
|
|
74
173
|
maxResultLength: 200, // result truncation (default: 80)
|
|
75
|
-
logger: (event) =>
|
|
174
|
+
logger: (event) => { // custom telemetry
|
|
175
|
+
myTracker.send(event);
|
|
176
|
+
},
|
|
76
177
|
});
|
|
77
178
|
```
|
|
78
179
|
|
|
79
|
-
Env: `MEASURE_SILENT=1`, `MEASURE_TIMESTAMPS=1`
|
|
180
|
+
Env vars: `MEASURE_SILENT=1`, `MEASURE_TIMESTAMPS=1`
|
|
80
181
|
|
|
81
|
-
##
|
|
182
|
+
## Programmatic Timing
|
|
82
183
|
|
|
83
184
|
```typescript
|
|
84
|
-
|
|
185
|
+
const { result, duration } = await measure.timed('Fetch', () => fetchUsers());
|
|
186
|
+
if (duration > 1000) alert('Slow!');
|
|
85
187
|
```
|
|
86
188
|
|
|
87
|
-
##
|
|
189
|
+
## Error Model — measure Never Throws
|
|
88
190
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
|
92
|
-
|
|
93
|
-
| `measure
|
|
94
|
-
| `measure.assert(
|
|
95
|
-
|
|
|
96
|
-
| `measure.batch(label, items, fn, opts?)` | Array processing with progress |
|
|
97
|
-
| `measureSync(label, fn?)` | Sync measurement |
|
|
98
|
-
| `measureSync.timed/assert/wrap` | Sync variants |
|
|
99
|
-
| `createMeasure(prefix)` | Scoped instance |
|
|
100
|
-
| `configure(opts)` | Runtime configuration |
|
|
101
|
-
| `resetCounter()` | Reset global ID counter |
|
|
102
|
-
| `safeStringify(value)` | Safe JSON with circular ref handling |
|
|
103
|
-
| `formatDuration(ms)` | Smart duration formatting |
|
|
191
|
+
Like Go's `result, err` pattern, `measure` treats errors as values: returns `null` on failure, **always** logs the error with timing and stack trace. One failing step doesn't crash the pipeline.
|
|
192
|
+
|
|
193
|
+
| Method | On error | Use when |
|
|
194
|
+
|--------|----------|----------|
|
|
195
|
+
| `measure()` | returns `null` | Default — resilient pipelines |
|
|
196
|
+
| `measure.assert()` | throws | Must have non-null (e.g. Bun.serve) |
|
|
197
|
+
| `?? fallback` | returns fallback | Graceful degradation |
|
|
104
198
|
|
|
105
|
-
##
|
|
199
|
+
## Anti-Patterns
|
|
106
200
|
|
|
107
201
|
```typescript
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
202
|
+
// ✗ Don't measure trivial synchronous expressions
|
|
203
|
+
const x = measureSync('Add', () => 1 + 1);
|
|
204
|
+
|
|
205
|
+
// ✗ Don't nest measure inside measure without using child `m`
|
|
206
|
+
await measure('Outer', async () => {
|
|
207
|
+
await measure('Inner', () => work()); // creates flat siblings, not hierarchy
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ✓ Use child measure for hierarchy
|
|
211
|
+
await measure('Outer', async (m) => {
|
|
212
|
+
await m('Inner', () => work()); // proper parent → child
|
|
112
213
|
});
|
|
113
214
|
```
|
|
215
|
+
|
|
216
|
+
## Quick Reference
|
|
217
|
+
|
|
218
|
+
| Export | Use |
|
|
219
|
+
|--------|-----|
|
|
220
|
+
| `measure(label, fn?)` | Async measurement |
|
|
221
|
+
| `measureSync(label, fn?)` | Sync measurement |
|
|
222
|
+
| `measure.wrap(label, fn)` | Decorator — wrap once, measure every call |
|
|
223
|
+
| `measure.batch(label, items, fn, opts?)` | Array + progress |
|
|
224
|
+
| `measure.retry(label, opts, fn)` | Retry with backoff |
|
|
225
|
+
| `measure.assert(label, fn)` | Throws if null |
|
|
226
|
+
| `measure.timed(label, fn)` | Returns `{ result, duration }` |
|
|
227
|
+
| `createMeasure(prefix)` | Scoped instance |
|
|
228
|
+
| `configure(opts)` | Runtime config |
|
|
229
|
+
| `safeStringify(value)` | Safe JSON (circular refs, truncation) |
|
|
230
|
+
| `formatDuration(ms)` | Smart duration: `0.10ms` → `1.2s` → `2m 5s` |
|
|
231
|
+
| `resetCounter()` | Reset ID counter |
|
package/bench.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
|
|
2
|
+
import { measure, measureSync, configure } from "./index.ts";
|
|
3
|
+
|
|
4
|
+
const ITERATIONS = 100_000;
|
|
5
|
+
|
|
6
|
+
function noop() { }
|
|
7
|
+
|
|
8
|
+
async function run() {
|
|
9
|
+
console.log(`Running benchmark with ${ITERATIONS.toLocaleString()} iterations...`);
|
|
10
|
+
configure({ silent: true }); // Disable logging to measure pure overhead
|
|
11
|
+
|
|
12
|
+
// Baseline
|
|
13
|
+
const startBase = performance.now();
|
|
14
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
15
|
+
noop();
|
|
16
|
+
}
|
|
17
|
+
const timeBase = performance.now() - startBase;
|
|
18
|
+
const perOpBase = timeBase / ITERATIONS;
|
|
19
|
+
console.log(`Baseline (noop): ${(timeBase).toFixed(2)}ms total, ${perOpBase.toFixed(6)}ms/op`);
|
|
20
|
+
|
|
21
|
+
// measureSync overhead
|
|
22
|
+
const startSync = performance.now();
|
|
23
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
24
|
+
measureSync('test', noop);
|
|
25
|
+
}
|
|
26
|
+
const timeSync = performance.now() - startSync;
|
|
27
|
+
const overheadSync = (timeSync - timeBase) / ITERATIONS;
|
|
28
|
+
console.log(`measureSync: ${(timeSync).toFixed(2)}ms total, ${overheadSync.toFixed(6)}ms overhead/op`);
|
|
29
|
+
|
|
30
|
+
// measure (async) overhead
|
|
31
|
+
const startAsync = performance.now();
|
|
32
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
33
|
+
await measure('test', async () => { });
|
|
34
|
+
}
|
|
35
|
+
const timeAsync = performance.now() - startAsync;
|
|
36
|
+
const overheadAsync = (timeAsync - timeBase) / ITERATIONS;
|
|
37
|
+
console.log(`measure (async): ${(timeAsync).toFixed(2)}ms total, ${overheadAsync.toFixed(6)}ms overhead/op`);
|
|
38
|
+
|
|
39
|
+
// Nested overhead (depth 3)
|
|
40
|
+
const startNested = performance.now();
|
|
41
|
+
for (let i = 0; i < ITERATIONS / 10; i++) { // reduce iterations
|
|
42
|
+
measureSync('root', (m) => {
|
|
43
|
+
m('child', (m2) => {
|
|
44
|
+
m2('leaf', noop);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const timeNested = performance.now() - startNested;
|
|
49
|
+
// 3 measurements per iteration
|
|
50
|
+
const perOpNested = timeNested / (ITERATIONS / 10);
|
|
51
|
+
console.log(`Nested (depth 3): ${(timeNested).toFixed(2)}ms total, ${perOpNested.toFixed(6)}ms per tree (3 ops)`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
run();
|
package/bun.lock
CHANGED
|
@@ -1,25 +1,26 @@
|
|
|
1
|
-
{
|
|
2
|
-
"lockfileVersion": 1,
|
|
3
|
-
"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"configVersion": 0,
|
|
4
|
+
"workspaces": {
|
|
5
|
+
"": {
|
|
6
|
+
"name": "ments-utils",
|
|
7
|
+
"devDependencies": {
|
|
8
|
+
"@types/bun": "latest",
|
|
9
|
+
},
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"typescript": "^5",
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
"packages": {
|
|
16
|
+
"@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="],
|
|
17
|
+
|
|
18
|
+
"@types/node": ["@types/node@24.0.4", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ulyqAkrhnuNq9pB76DRBTkcS6YsmDALy6Ua63V8OhrOBgbcYt6IOdzpw5P1+dyRIyMerzLkeYWBeOXPpA9GMAA=="],
|
|
19
|
+
|
|
20
|
+
"bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="],
|
|
21
|
+
|
|
22
|
+
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
|
23
|
+
|
|
24
|
+
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
|
|
25
|
+
}
|
|
26
|
+
}
|
package/example.ts
CHANGED
|
@@ -98,4 +98,60 @@ async function main() {
|
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
// ─── Bun.serve patterns ──────────────────────────────────────────────
|
|
102
|
+
// measure() returns T | null — on error it returns null instead of throwing.
|
|
103
|
+
// In Bun.serve, the fetch handler MUST return a Response. If measure()
|
|
104
|
+
// swallows the error and returns null, Bun crashes.
|
|
105
|
+
//
|
|
106
|
+
// Solution: use measure.assert() which re-throws on error,
|
|
107
|
+
// or use nullish coalescing to provide a fallback Response.
|
|
108
|
+
|
|
109
|
+
async function bunServeExample() {
|
|
110
|
+
console.log('\n─── Bun.serve Patterns ─────────────────────────────');
|
|
111
|
+
|
|
112
|
+
// ✅ Pattern 1: measure.assert — throws on error, Bun gets a proper crash
|
|
113
|
+
const server1 = Bun.serve({
|
|
114
|
+
port: 0, // random port
|
|
115
|
+
fetch: (req) => measure.assert('Handle request', async () => {
|
|
116
|
+
const url = new URL(req.url);
|
|
117
|
+
if (url.pathname === '/fail') throw new Error('Route error');
|
|
118
|
+
return new Response(`ok: ${url.pathname}`);
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ✅ Pattern 2: nullish coalescing — graceful 500 fallback
|
|
123
|
+
const server2 = Bun.serve({
|
|
124
|
+
port: 0,
|
|
125
|
+
fetch: async (req) => {
|
|
126
|
+
return (await measure('Handle request', async () => {
|
|
127
|
+
const url = new URL(req.url);
|
|
128
|
+
if (url.pathname === '/fail') throw new Error('Route error');
|
|
129
|
+
return new Response(`ok: ${url.pathname}`);
|
|
130
|
+
})) ?? new Response('Internal Server Error', { status: 500 });
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Test both servers
|
|
135
|
+
const r1 = await fetch(`http://localhost:${server1.port}/hello`);
|
|
136
|
+
console.log(` assert pattern (ok): ${r1.status} ${await r1.text()}`);
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await fetch(`http://localhost:${server1.port}/fail`);
|
|
140
|
+
} catch {
|
|
141
|
+
console.log(` assert pattern (fail): server rejected (expected)`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const r2ok = await fetch(`http://localhost:${server2.port}/hello`);
|
|
145
|
+
console.log(` fallback pattern (ok): ${r2ok.status} ${await r2ok.text()}`);
|
|
146
|
+
|
|
147
|
+
const r2fail = await fetch(`http://localhost:${server2.port}/fail`);
|
|
148
|
+
console.log(` fallback pattern (fail): ${r2fail.status} ${await r2fail.text()}`);
|
|
149
|
+
|
|
150
|
+
server1.stop();
|
|
151
|
+
server2.stop();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
main()
|
|
155
|
+
.then(() => console.log('\n✅ Done.'))
|
|
156
|
+
.then(() => bunServeExample())
|
|
157
|
+
.then(() => console.log('\n✅ Bun.serve example done.'));
|
package/index.test.ts
CHANGED
|
@@ -394,13 +394,15 @@ describe("measure.assert", () => {
|
|
|
394
394
|
expect(r).toBe(42);
|
|
395
395
|
});
|
|
396
396
|
|
|
397
|
-
test("throws on error", async () => {
|
|
397
|
+
test("throws on error with original cause", async () => {
|
|
398
398
|
const out = captureConsole();
|
|
399
|
+
const original = new Error("connection refused");
|
|
399
400
|
try {
|
|
400
|
-
await measure.assert("fail", async () => { throw
|
|
401
|
+
await measure.assert("fail", async () => { throw original; });
|
|
401
402
|
expect(true).toBe(false);
|
|
402
403
|
} catch (e: any) {
|
|
403
404
|
expect(e.message).toContain("fail");
|
|
405
|
+
expect(e.cause).toBe(original);
|
|
404
406
|
}
|
|
405
407
|
out.restore();
|
|
406
408
|
});
|
|
@@ -411,13 +413,15 @@ describe("measure.assert", () => {
|
|
|
411
413
|
out.restore();
|
|
412
414
|
});
|
|
413
415
|
|
|
414
|
-
test("sync assert throws", () => {
|
|
416
|
+
test("sync assert throws with original cause", () => {
|
|
415
417
|
const out = captureConsole();
|
|
418
|
+
const original = new Error("parse error");
|
|
416
419
|
try {
|
|
417
|
-
measureSync.assert("fail", () => { throw
|
|
420
|
+
measureSync.assert("fail", () => { throw original; });
|
|
418
421
|
expect(true).toBe(false);
|
|
419
422
|
} catch (e: any) {
|
|
420
423
|
expect(e.message).toContain("fail");
|
|
424
|
+
expect(e.cause).toBe(original);
|
|
421
425
|
}
|
|
422
426
|
out.restore();
|
|
423
427
|
});
|
|
@@ -585,3 +589,60 @@ describe("ID generation", () => {
|
|
|
585
589
|
expect(out.logs[52]).toStartWith("[aa]");
|
|
586
590
|
});
|
|
587
591
|
});
|
|
592
|
+
|
|
593
|
+
// ─── Bun.serve patterns ─────────────────────────────────────────────
|
|
594
|
+
|
|
595
|
+
describe("Bun.serve pattern", () => {
|
|
596
|
+
beforeEach(() => {
|
|
597
|
+
resetCounter();
|
|
598
|
+
configure({ silent: false, logger: null, timestamps: false });
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("measure returns null on error — breaks fetch handler", async () => {
|
|
602
|
+
const out = captureConsole();
|
|
603
|
+
const result = await measure("handle", async () => {
|
|
604
|
+
throw new Error("route error");
|
|
605
|
+
return new Response("ok");
|
|
606
|
+
});
|
|
607
|
+
out.restore();
|
|
608
|
+
expect(result).toBeNull(); // Bun.serve would crash with null
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("measure.assert returns Response on success", async () => {
|
|
612
|
+
const out = captureConsole();
|
|
613
|
+
const result = await measure.assert("handle", async () => {
|
|
614
|
+
return new Response("ok");
|
|
615
|
+
});
|
|
616
|
+
out.restore();
|
|
617
|
+
expect(result).toBeInstanceOf(Response);
|
|
618
|
+
expect(await result.text()).toBe("ok");
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test("measure.assert throws on error with cause — Bun.serve can catch it", async () => {
|
|
622
|
+
const out = captureConsole();
|
|
623
|
+
const original = new Error("route error");
|
|
624
|
+
try {
|
|
625
|
+
await measure.assert("handle", async () => {
|
|
626
|
+
throw original;
|
|
627
|
+
return new Response("ok");
|
|
628
|
+
});
|
|
629
|
+
expect(true).toBe(false); // should not reach
|
|
630
|
+
} catch (e: any) {
|
|
631
|
+
expect(e.message).toContain("handle");
|
|
632
|
+
expect(e.cause).toBe(original);
|
|
633
|
+
}
|
|
634
|
+
out.restore();
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("nullish coalescing fallback pattern", async () => {
|
|
638
|
+
const out = captureConsole();
|
|
639
|
+
const result = (await measure("handle", async () => {
|
|
640
|
+
throw new Error("route error");
|
|
641
|
+
return new Response("ok");
|
|
642
|
+
})) ?? new Response("Internal Server Error", { status: 500 });
|
|
643
|
+
out.restore();
|
|
644
|
+
expect(result).toBeInstanceOf(Response);
|
|
645
|
+
expect(result.status).toBe(500);
|
|
646
|
+
expect(await result.text()).toBe("Internal Server Error");
|
|
647
|
+
});
|
|
648
|
+
});
|
package/index.ts
CHANGED
|
@@ -249,6 +249,7 @@ export const resetCounter = () => {
|
|
|
249
249
|
|
|
250
250
|
const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
251
251
|
const counter = counterRef ?? { get value() { return globalRootCounter; }, set value(v) { globalRootCounter = v; } };
|
|
252
|
+
let _lastError: unknown = null;
|
|
252
253
|
|
|
253
254
|
const _measureInternal = async <U>(
|
|
254
255
|
fnInternal: (measure: MeasureFn) => Promise<U>,
|
|
@@ -283,6 +284,7 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
283
284
|
} catch (error) {
|
|
284
285
|
const duration = performance.now() - start;
|
|
285
286
|
emit({ type: 'error', id: idStr, label, depth, duration, error, budget }, prefix);
|
|
287
|
+
_lastError = error;
|
|
286
288
|
return null;
|
|
287
289
|
}
|
|
288
290
|
};
|
|
@@ -323,6 +325,7 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
323
325
|
} catch (error) {
|
|
324
326
|
const duration = performance.now() - start;
|
|
325
327
|
emit({ type: 'error', id: idStr, label, depth, duration, error, budget }, prefix);
|
|
328
|
+
_lastError = error;
|
|
326
329
|
return null;
|
|
327
330
|
}
|
|
328
331
|
};
|
|
@@ -405,7 +408,9 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
405
408
|
): Promise<T> => {
|
|
406
409
|
const result = await measureFn(arg1, arg2 as any);
|
|
407
410
|
if (result === null) {
|
|
408
|
-
|
|
411
|
+
const cause = _lastError;
|
|
412
|
+
_lastError = null;
|
|
413
|
+
throw new Error(`measure.assert: "${buildActionLabel(arg1)}" failed`, { cause });
|
|
409
414
|
}
|
|
410
415
|
return result;
|
|
411
416
|
};
|
|
@@ -507,7 +512,9 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
507
512
|
): T => {
|
|
508
513
|
const result = measureSyncFn(arg1, arg2 as any);
|
|
509
514
|
if (result === null) {
|
|
510
|
-
|
|
515
|
+
const cause = _lastError;
|
|
516
|
+
_lastError = null;
|
|
517
|
+
throw new Error(`measureSync.assert: "${buildActionLabel(arg1)}" failed`, { cause });
|
|
511
518
|
}
|
|
512
519
|
return result;
|
|
513
520
|
};
|
package/package.json
CHANGED