measure-fn 3.2.1 → 3.5.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/README.md +84 -3
- package/SKILL.md +192 -61
- package/bun.lock +26 -25
- package/example.ts +56 -1
- package/index.test.ts +173 -4
- package/index.ts +21 -8
- package/package.json +3 -2
- package/.github/workflows/ci.yml +0 -20
package/README.md
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# measure-fn
|
|
2
2
|
|
|
3
|
-

|
|
4
|
-
|
|
5
3
|
**Stop writing blind code.** Every function you write either succeeds or fails, takes some amount of time, and lives inside a larger flow. `measure-fn` makes all of that visible — automatically, hierarchically.
|
|
6
4
|
|
|
7
5
|
```
|
|
@@ -164,6 +162,31 @@ await api.measure('GET /users', async () => {
|
|
|
164
162
|
// → [api:a] ✓ GET /users 45ms → [...]
|
|
165
163
|
```
|
|
166
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
|
+
|
|
167
190
|
### `configure(opts)` — runtime config
|
|
168
191
|
|
|
169
192
|
```typescript
|
|
@@ -197,7 +220,65 @@ resetCounter(); // reset ID counter for tests
|
|
|
197
220
|
|
|
198
221
|
## Error Handling
|
|
199
222
|
|
|
200
|
-
|
|
223
|
+
`measure` never throws. On error it logs `✗` with timing and stack trace, then returns `null`. This keeps pipelines resilient — one failing step doesn't crash the rest.
|
|
224
|
+
|
|
225
|
+
**When you need to handle the error**, pass an `onError` handler as the 3rd argument. It receives the original error and its return value replaces `null`:
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
// Default: returns null on error
|
|
229
|
+
const user = await measure('Fetch user', () => fetchUser(1));
|
|
230
|
+
|
|
231
|
+
// With recovery: returns fallback on error
|
|
232
|
+
const user = await measure('Fetch user', () => fetchUser(1),
|
|
233
|
+
(error) => defaultUser
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// With error inspection: handle known errors, rethrow unknown
|
|
237
|
+
const user = await measure('Fetch user', () => fetchUser(1),
|
|
238
|
+
(error) => {
|
|
239
|
+
if (error instanceof NotFoundError) return guestUser;
|
|
240
|
+
if (error instanceof NetworkError) return cachedUser;
|
|
241
|
+
throw error; // unexpected — propagates up
|
|
242
|
+
}
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
// Rethrow all: transparent observability (same as .assert())
|
|
246
|
+
const user = await measure('Fetch user', () => fetchUser(1),
|
|
247
|
+
(error) => { throw error }
|
|
248
|
+
);
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**Bun.serve — never return null:**
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
Bun.serve({
|
|
255
|
+
fetch: (req) => measure(
|
|
256
|
+
{ label: `${req.method} ${req.url}` },
|
|
257
|
+
() => handleRequest(req),
|
|
258
|
+
(error) => new Response(`Error: ${error.message}`, { status: 500 })
|
|
259
|
+
),
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
**`.assert()` is sugar for the rethrow pattern:**
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
// These are equivalent:
|
|
267
|
+
await measure.assert('Op', () => work());
|
|
268
|
+
await measure('Op', () => work(), (e) => { throw e });
|
|
269
|
+
|
|
270
|
+
// .assert() wraps the error with .cause for inspection:
|
|
271
|
+
// e.message → 'measure.assert: "Op" failed'
|
|
272
|
+
// e.cause → original error
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**Summary:**
|
|
276
|
+
|
|
277
|
+
| Pattern | On error | Use when |
|
|
278
|
+
|---------|----------|----------|
|
|
279
|
+
| `measure(label, fn)` | logs `✗`, returns `null` | Default — pipeline resilience |
|
|
280
|
+
| `measure(label, fn, onError)` | logs `✗`, calls `onError(error)` | Recovery, fallbacks, error inspection |
|
|
281
|
+
| `measure.assert(label, fn)` | logs `✗`, throws with `.cause` | Must have non-null |
|
|
201
282
|
|
|
202
283
|
## Types
|
|
203
284
|
|
package/SKILL.md
CHANGED
|
@@ -3,111 +3,242 @@ 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
|
+
```
|
|
45
78
|
|
|
46
|
-
|
|
79
|
+
### 4. Wrap reusable functions once
|
|
80
|
+
|
|
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
|
+
```
|
|
50
87
|
|
|
51
|
-
|
|
52
|
-
await measure.batch('Process', items, async (item) => transform(item), { every: 100 });
|
|
88
|
+
### 5. Process arrays with progress
|
|
53
89
|
|
|
54
|
-
|
|
55
|
-
await measure.
|
|
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
|
+
```
|
|
56
98
|
|
|
57
|
-
|
|
58
|
-
const user = await measure.assert('Get user', () => fetchUser(1));
|
|
99
|
+
### 6. Retry flaky operations
|
|
59
100
|
|
|
60
|
-
|
|
61
|
-
await measure(
|
|
101
|
+
```typescript
|
|
102
|
+
const result = await measure.retry('External API', {
|
|
103
|
+
attempts: 3, delay: 1000, backoff: 2
|
|
104
|
+
}, () => callExternalService());
|
|
105
|
+
```
|
|
62
106
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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)
|
|
66
112
|
```
|
|
67
113
|
|
|
114
|
+
### 8. Assert non-null results
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
// Guaranteed non-null — throws if the function returns null/undefined
|
|
118
|
+
const user = await measure.assert('Get user', () => findUser(id));
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### 9. Scoped instances for subsystems
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
const api = createMeasure('api');
|
|
125
|
+
const db = createMeasure('db');
|
|
126
|
+
|
|
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. Error handling — `onError` 3rd argument
|
|
143
|
+
|
|
144
|
+
`measure` never throws. Pass an `onError` handler as 3rd argument to handle errors:
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
// Default: null on error
|
|
148
|
+
const user = await measure('Fetch user', () => fetchUser(1));
|
|
149
|
+
|
|
150
|
+
// Recovery: fallback on error
|
|
151
|
+
const user = await measure('Fetch user', () => fetchUser(1),
|
|
152
|
+
(error) => defaultUser
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Error inspection: handle known errors, rethrow unknown
|
|
156
|
+
const user = await measure('Fetch user', () => fetchUser(1),
|
|
157
|
+
(error) => {
|
|
158
|
+
if (error instanceof NetworkError) return cachedUser;
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// Bun.serve: always return a Response
|
|
164
|
+
Bun.serve({
|
|
165
|
+
fetch: (req) => measure(
|
|
166
|
+
{ label: `${req.method} ${req.url}` },
|
|
167
|
+
() => handleRequest(req),
|
|
168
|
+
(error) => new Response('Internal Server Error', { status: 500 })
|
|
169
|
+
),
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`.assert()` is sugar for `(e) => { throw e }` with `.cause`:
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
await measure.assert('Op', () => work());
|
|
177
|
+
// equivalent to: measure('Op', () => work(), (e) => { throw e })
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Error Model
|
|
181
|
+
|
|
182
|
+
| Pattern | On error | Use when |
|
|
183
|
+
|---------|----------|----------|
|
|
184
|
+
| `measure(label, fn)` | logs `✗`, returns `null` | Default — pipeline resilience |
|
|
185
|
+
| `measure(label, fn, onError)` | logs `✗`, calls `onError(error)` | Recovery, fallbacks, error inspection |
|
|
186
|
+
| `measure.assert(label, fn)` | logs `✗`, throws with `.cause` | Must have non-null |
|
|
187
|
+
|
|
68
188
|
## Configuration
|
|
69
189
|
|
|
70
190
|
```typescript
|
|
191
|
+
import { configure } from 'measure-fn';
|
|
192
|
+
|
|
71
193
|
configure({
|
|
72
|
-
silent: true, // suppress output
|
|
194
|
+
silent: true, // suppress output (for benchmarks)
|
|
73
195
|
timestamps: true, // [HH:MM:SS.mmm] prefix
|
|
74
196
|
maxResultLength: 200, // result truncation (default: 80)
|
|
75
|
-
logger: (event) =>
|
|
197
|
+
logger: (event) => { // custom telemetry
|
|
198
|
+
myTracker.send(event);
|
|
199
|
+
},
|
|
76
200
|
});
|
|
77
201
|
```
|
|
78
202
|
|
|
79
|
-
Env: `MEASURE_SILENT=1`, `MEASURE_TIMESTAMPS=1`
|
|
203
|
+
Env vars: `MEASURE_SILENT=1`, `MEASURE_TIMESTAMPS=1`
|
|
80
204
|
|
|
81
|
-
##
|
|
205
|
+
## Programmatic Timing
|
|
82
206
|
|
|
83
207
|
```typescript
|
|
84
|
-
|
|
208
|
+
const { result, duration } = await measure.timed('Fetch', () => fetchUsers());
|
|
209
|
+
if (duration > 1000) alert('Slow!');
|
|
85
210
|
```
|
|
86
211
|
|
|
87
|
-
##
|
|
212
|
+
## Anti-Patterns
|
|
88
213
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
| `measure.timed(label, fn?)` | Returns `{ result, duration }` |
|
|
93
|
-
| `measure.retry(label, opts, fn)` | Retry with backoff |
|
|
94
|
-
| `measure.assert(label, fn)` | Throws if null |
|
|
95
|
-
| `measure.wrap(label, fn)` | Returns measured version of fn |
|
|
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 |
|
|
214
|
+
```typescript
|
|
215
|
+
// ✗ Don't measure trivial synchronous expressions
|
|
216
|
+
const x = measureSync('Add', () => 1 + 1);
|
|
104
217
|
|
|
105
|
-
|
|
218
|
+
// ✗ Don't nest measure inside measure without using child `m`
|
|
219
|
+
await measure('Outer', async () => {
|
|
220
|
+
await measure('Inner', () => work()); // creates flat siblings, not hierarchy
|
|
221
|
+
});
|
|
106
222
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
resetCounter();
|
|
111
|
-
configure({ silent: false, logger: null, timestamps: false });
|
|
223
|
+
// ✓ Use child measure for hierarchy
|
|
224
|
+
await measure('Outer', async (m) => {
|
|
225
|
+
await m('Inner', () => work()); // proper parent → child
|
|
112
226
|
});
|
|
113
227
|
```
|
|
228
|
+
|
|
229
|
+
## Quick Reference
|
|
230
|
+
|
|
231
|
+
| Export | Use |
|
|
232
|
+
|--------|-----|
|
|
233
|
+
| `measure(label, fn?)` | Async measurement |
|
|
234
|
+
| `measureSync(label, fn?)` | Sync measurement |
|
|
235
|
+
| `measure.wrap(label, fn)` | Decorator — wrap once, measure every call |
|
|
236
|
+
| `measure.batch(label, items, fn, opts?)` | Array + progress |
|
|
237
|
+
| `measure.retry(label, opts, fn)` | Retry with backoff |
|
|
238
|
+
| `measure.assert(label, fn)` | Throws if null |
|
|
239
|
+
| `measure.timed(label, fn)` | Returns `{ result, duration }` |
|
|
240
|
+
| `createMeasure(prefix)` | Scoped instance |
|
|
241
|
+
| `configure(opts)` | Runtime config |
|
|
242
|
+
| `safeStringify(value)` | Safe JSON (circular refs, truncation) |
|
|
243
|
+
| `formatDuration(ms)` | Smart duration: `0.10ms` → `1.2s` → `2m 5s` |
|
|
244
|
+
| `resetCounter()` | Reset ID counter |
|
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,59 @@ 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
|
+
// Use the onError 3rd argument to provide a fallback Response.
|
|
104
|
+
|
|
105
|
+
async function bunServeExample() {
|
|
106
|
+
console.log('\n─── Bun.serve Patterns ─────────────────────────────');
|
|
107
|
+
|
|
108
|
+
// ✅ Pattern 1: onError — graceful 500 fallback with error details
|
|
109
|
+
const server1 = Bun.serve({
|
|
110
|
+
port: 0,
|
|
111
|
+
fetch: (req) => measure(
|
|
112
|
+
{ label: `${req.method} ${new URL(req.url).pathname}` },
|
|
113
|
+
async () => {
|
|
114
|
+
const url = new URL(req.url);
|
|
115
|
+
if (url.pathname === '/fail') throw new Error('Route error');
|
|
116
|
+
return new Response(`ok: ${url.pathname}`);
|
|
117
|
+
},
|
|
118
|
+
(error) => new Response(`Error: ${(error as Error).message}`, { status: 500 })
|
|
119
|
+
),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ✅ Pattern 2: measure.assert — throws on error (sugar for onError + throw)
|
|
123
|
+
const server2 = Bun.serve({
|
|
124
|
+
port: 0,
|
|
125
|
+
fetch: (req) => measure.assert('Handle request', async () => {
|
|
126
|
+
const url = new URL(req.url);
|
|
127
|
+
if (url.pathname === '/fail') throw new Error('Route error');
|
|
128
|
+
return new Response(`ok: ${url.pathname}`);
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Test Pattern 1: onError returns fallback Response
|
|
133
|
+
const r1ok = await fetch(`http://localhost:${server1.port}/hello`);
|
|
134
|
+
console.log(` onError pattern (ok): ${r1ok.status} ${await r1ok.text()}`);
|
|
135
|
+
|
|
136
|
+
const r1fail = await fetch(`http://localhost:${server1.port}/fail`);
|
|
137
|
+
console.log(` onError pattern (fail): ${r1fail.status} ${await r1fail.text()}`);
|
|
138
|
+
|
|
139
|
+
// Test Pattern 2: assert
|
|
140
|
+
const r2ok = await fetch(`http://localhost:${server2.port}/hello`);
|
|
141
|
+
console.log(` assert pattern (ok): ${r2ok.status} ${await r2ok.text()}`);
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
await fetch(`http://localhost:${server2.port}/fail`);
|
|
145
|
+
} catch {
|
|
146
|
+
console.log(` assert pattern (fail): server rejected (expected)`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
server1.stop();
|
|
150
|
+
server2.stop();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
main()
|
|
154
|
+
.then(() => console.log('\n✅ Done.'))
|
|
155
|
+
.then(() => bunServeExample())
|
|
156
|
+
.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,18 +413,128 @@ 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
|
});
|
|
424
428
|
});
|
|
425
429
|
|
|
430
|
+
// ─── onError (3rd argument) ──────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
describe("onError (3rd argument)", () => {
|
|
433
|
+
beforeEach(() => {
|
|
434
|
+
resetCounter();
|
|
435
|
+
configure({ silent: false, logger: null, timestamps: false });
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("returns fallback from onError on failure", async () => {
|
|
439
|
+
const out = captureConsole();
|
|
440
|
+
const result = await measure('Fetch user', async () => {
|
|
441
|
+
throw new Error('not found');
|
|
442
|
+
}, () => 'fallback');
|
|
443
|
+
out.restore();
|
|
444
|
+
expect(result).toBe('fallback');
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("returns normal result on success (onError ignored)", async () => {
|
|
448
|
+
const out = captureConsole();
|
|
449
|
+
const result = await measure('Fetch user', async () => 42, () => -1);
|
|
450
|
+
out.restore();
|
|
451
|
+
expect(result).toBe(42);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("error object is passed to onError handler", async () => {
|
|
455
|
+
const out = captureConsole();
|
|
456
|
+
const original = new Error('network timeout');
|
|
457
|
+
let captured: unknown = null;
|
|
458
|
+
await measure('Fetch', async () => { throw original; }, (err) => {
|
|
459
|
+
captured = err;
|
|
460
|
+
return null;
|
|
461
|
+
});
|
|
462
|
+
out.restore();
|
|
463
|
+
expect(captured).toBe(original);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("onError can rethrow (same as assert)", async () => {
|
|
467
|
+
const out = captureConsole();
|
|
468
|
+
const original = new Error('critical');
|
|
469
|
+
try {
|
|
470
|
+
await measure('Op', async () => { throw original; }, (e) => { throw e; });
|
|
471
|
+
expect(true).toBe(false);
|
|
472
|
+
} catch (e) {
|
|
473
|
+
expect(e).toBe(original);
|
|
474
|
+
}
|
|
475
|
+
out.restore();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("onError can inspect error type and recover", async () => {
|
|
479
|
+
const out = captureConsole();
|
|
480
|
+
const result = await measure('Fetch', async () => {
|
|
481
|
+
throw new TypeError('invalid');
|
|
482
|
+
}, (error) => {
|
|
483
|
+
if (error instanceof TypeError) return 'recovered';
|
|
484
|
+
throw error;
|
|
485
|
+
});
|
|
486
|
+
out.restore();
|
|
487
|
+
expect(result).toBe('recovered');
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("sync onError returns fallback", () => {
|
|
491
|
+
const out = captureConsole();
|
|
492
|
+
const result = measureSync('Parse', () => {
|
|
493
|
+
throw new Error('bad input');
|
|
494
|
+
}, () => 'default');
|
|
495
|
+
out.restore();
|
|
496
|
+
expect(result).toBe('default');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test("sync onError receives error", () => {
|
|
500
|
+
const out = captureConsole();
|
|
501
|
+
const original = new Error('sync fail');
|
|
502
|
+
let captured: unknown = null;
|
|
503
|
+
measureSync('Op', () => { throw original; }, (err) => {
|
|
504
|
+
captured = err;
|
|
505
|
+
return null;
|
|
506
|
+
});
|
|
507
|
+
out.restore();
|
|
508
|
+
expect(captured).toBe(original);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("still logs error even when onError handles it", async () => {
|
|
512
|
+
const events: any[] = [];
|
|
513
|
+
configure({ logger: (e) => events.push(e) });
|
|
514
|
+
await measure('Op', async () => { throw new Error('x'); }, () => 'fallback');
|
|
515
|
+
configure({ logger: null });
|
|
516
|
+
const errorEvent = events.find(e => e.type === 'error');
|
|
517
|
+
expect(errorEvent).toBeTruthy();
|
|
518
|
+
expect(errorEvent.label).toBe('Op');
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("Bun.serve pattern with onError fallback", async () => {
|
|
522
|
+
const out = captureConsole();
|
|
523
|
+
const result = await measure(
|
|
524
|
+
{ label: 'Handle request' },
|
|
525
|
+
async () => {
|
|
526
|
+
throw new Error('route error');
|
|
527
|
+
return new Response('ok');
|
|
528
|
+
},
|
|
529
|
+
(error) => new Response(`Error: ${(error as Error).message}`, { status: 500 })
|
|
530
|
+
);
|
|
531
|
+
out.restore();
|
|
532
|
+
expect(result).toBeInstanceOf(Response);
|
|
533
|
+
expect(result!.status).toBe(500);
|
|
534
|
+
expect(await result!.text()).toContain('route error');
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
|
|
426
538
|
// ─── measure.wrap ────────────────────────────────────────────────────
|
|
427
539
|
|
|
428
540
|
describe("measure.wrap", () => {
|
|
@@ -585,3 +697,60 @@ describe("ID generation", () => {
|
|
|
585
697
|
expect(out.logs[52]).toStartWith("[aa]");
|
|
586
698
|
});
|
|
587
699
|
});
|
|
700
|
+
|
|
701
|
+
// ─── Bun.serve patterns ─────────────────────────────────────────────
|
|
702
|
+
|
|
703
|
+
describe("Bun.serve pattern", () => {
|
|
704
|
+
beforeEach(() => {
|
|
705
|
+
resetCounter();
|
|
706
|
+
configure({ silent: false, logger: null, timestamps: false });
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test("measure returns null on error — breaks fetch handler", async () => {
|
|
710
|
+
const out = captureConsole();
|
|
711
|
+
const result = await measure("handle", async () => {
|
|
712
|
+
throw new Error("route error");
|
|
713
|
+
return new Response("ok");
|
|
714
|
+
});
|
|
715
|
+
out.restore();
|
|
716
|
+
expect(result).toBeNull(); // Bun.serve would crash with null
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
test("measure.assert returns Response on success", async () => {
|
|
720
|
+
const out = captureConsole();
|
|
721
|
+
const result = await measure.assert("handle", async () => {
|
|
722
|
+
return new Response("ok");
|
|
723
|
+
});
|
|
724
|
+
out.restore();
|
|
725
|
+
expect(result).toBeInstanceOf(Response);
|
|
726
|
+
expect(await result.text()).toBe("ok");
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test("measure.assert throws on error with cause — Bun.serve can catch it", async () => {
|
|
730
|
+
const out = captureConsole();
|
|
731
|
+
const original = new Error("route error");
|
|
732
|
+
try {
|
|
733
|
+
await measure.assert("handle", async () => {
|
|
734
|
+
throw original;
|
|
735
|
+
return new Response("ok");
|
|
736
|
+
});
|
|
737
|
+
expect(true).toBe(false); // should not reach
|
|
738
|
+
} catch (e: any) {
|
|
739
|
+
expect(e.message).toContain("handle");
|
|
740
|
+
expect(e.cause).toBe(original);
|
|
741
|
+
}
|
|
742
|
+
out.restore();
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
test("nullish coalescing fallback pattern", async () => {
|
|
746
|
+
const out = captureConsole();
|
|
747
|
+
const result = (await measure("handle", async () => {
|
|
748
|
+
throw new Error("route error");
|
|
749
|
+
return new Response("ok");
|
|
750
|
+
})) ?? new Response("Internal Server Error", { status: 500 });
|
|
751
|
+
out.restore();
|
|
752
|
+
expect(result).toBeInstanceOf(Response);
|
|
753
|
+
expect(result.status).toBe(500);
|
|
754
|
+
expect(await result.text()).toBe("Internal Server Error");
|
|
755
|
+
});
|
|
756
|
+
});
|
package/index.ts
CHANGED
|
@@ -214,16 +214,17 @@ const createNestedResolver = (
|
|
|
214
214
|
fullIdChain: string[],
|
|
215
215
|
childCounterRef: { value: number },
|
|
216
216
|
depth: number,
|
|
217
|
-
resolver: <U>(fn: any, action: any, chain: (string | number)[], depth: number) => Promise<U | null> | (U | null),
|
|
217
|
+
resolver: <U>(fn: any, action: any, chain: (string | number)[], depth: number, onError?: (error: unknown) => any) => Promise<U | null> | (U | null),
|
|
218
218
|
prefix?: string
|
|
219
219
|
) => {
|
|
220
220
|
return (...args: any[]) => {
|
|
221
221
|
const label = args[0];
|
|
222
222
|
const fn = args[1];
|
|
223
|
+
const onError = args[2];
|
|
223
224
|
|
|
224
225
|
if (typeof fn === 'function') {
|
|
225
226
|
const childParentChain = [...fullIdChain, childCounterRef.value++];
|
|
226
|
-
return resolver(fn, label, childParentChain, depth + 1);
|
|
227
|
+
return resolver(fn, label, childParentChain, depth + 1, typeof onError === 'function' ? onError : undefined);
|
|
227
228
|
} else {
|
|
228
229
|
emit({
|
|
229
230
|
type: 'annotation',
|
|
@@ -249,12 +250,14 @@ export const resetCounter = () => {
|
|
|
249
250
|
|
|
250
251
|
const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
251
252
|
const counter = counterRef ?? { get value() { return globalRootCounter; }, set value(v) { globalRootCounter = v; } };
|
|
253
|
+
let _lastError: unknown = null;
|
|
252
254
|
|
|
253
255
|
const _measureInternal = async <U>(
|
|
254
256
|
fnInternal: (measure: MeasureFn) => Promise<U>,
|
|
255
257
|
actionInternal: string | object,
|
|
256
258
|
parentIdChain: (string | number)[],
|
|
257
|
-
depth: number
|
|
259
|
+
depth: number,
|
|
260
|
+
onError?: (error: unknown) => any
|
|
258
261
|
): Promise<U | null> => {
|
|
259
262
|
const start = performance.now();
|
|
260
263
|
const childCounterRef = { value: 0 };
|
|
@@ -283,6 +286,8 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
283
286
|
} catch (error) {
|
|
284
287
|
const duration = performance.now() - start;
|
|
285
288
|
emit({ type: 'error', id: idStr, label, depth, duration, error, budget }, prefix);
|
|
289
|
+
_lastError = error;
|
|
290
|
+
if (onError) return onError(error);
|
|
286
291
|
return null;
|
|
287
292
|
}
|
|
288
293
|
};
|
|
@@ -291,7 +296,8 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
291
296
|
fnInternal: (measure: MeasureSyncFn) => U,
|
|
292
297
|
actionInternal: string | object,
|
|
293
298
|
parentIdChain: (string | number)[],
|
|
294
|
-
depth: number
|
|
299
|
+
depth: number,
|
|
300
|
+
onError?: (error: unknown) => any
|
|
295
301
|
): U | null => {
|
|
296
302
|
const start = performance.now();
|
|
297
303
|
const childCounterRef = { value: 0 };
|
|
@@ -323,6 +329,8 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
323
329
|
} catch (error) {
|
|
324
330
|
const duration = performance.now() - start;
|
|
325
331
|
emit({ type: 'error', id: idStr, label, depth, duration, error, budget }, prefix);
|
|
332
|
+
_lastError = error;
|
|
333
|
+
if (onError) return onError(error);
|
|
326
334
|
return null;
|
|
327
335
|
}
|
|
328
336
|
};
|
|
@@ -331,10 +339,11 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
331
339
|
|
|
332
340
|
const measureFn = async <T = null>(
|
|
333
341
|
arg1: string | object,
|
|
334
|
-
arg2?: ((measure: MeasureFn) => Promise<T>)
|
|
342
|
+
arg2?: ((measure: MeasureFn) => Promise<T>) | ((measure: MeasureFn) => T),
|
|
343
|
+
arg3?: (error: unknown) => any
|
|
335
344
|
): Promise<T | null> => {
|
|
336
345
|
if (typeof arg2 === 'function') {
|
|
337
|
-
return _measureInternal(arg2, arg1, [counter.value++], 0) as Promise<T | null>;
|
|
346
|
+
return _measureInternal(arg2 as any, arg1, [counter.value++], 0, arg3) as Promise<T | null>;
|
|
338
347
|
} else {
|
|
339
348
|
const currentId = toAlpha(counter.value++);
|
|
340
349
|
emit({
|
|
@@ -405,7 +414,9 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
405
414
|
): Promise<T> => {
|
|
406
415
|
const result = await measureFn(arg1, arg2 as any);
|
|
407
416
|
if (result === null) {
|
|
408
|
-
|
|
417
|
+
const cause = _lastError;
|
|
418
|
+
_lastError = null;
|
|
419
|
+
throw new Error(`measure.assert: "${buildActionLabel(arg1)}" failed`, { cause });
|
|
409
420
|
}
|
|
410
421
|
return result;
|
|
411
422
|
};
|
|
@@ -507,7 +518,9 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
507
518
|
): T => {
|
|
508
519
|
const result = measureSyncFn(arg1, arg2 as any);
|
|
509
520
|
if (result === null) {
|
|
510
|
-
|
|
521
|
+
const cause = _lastError;
|
|
522
|
+
_lastError = null;
|
|
523
|
+
throw new Error(`measureSync.assert: "${buildActionLabel(arg1)}" failed`, { cause });
|
|
511
524
|
}
|
|
512
525
|
return result;
|
|
513
526
|
};
|
package/package.json
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
"name": "measure-fn",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"main": "./index.ts",
|
|
5
|
-
"
|
|
5
|
+
"types": "./index.ts",
|
|
6
|
+
"version": "3.5.0",
|
|
6
7
|
"type": "module",
|
|
7
8
|
"private": false,
|
|
8
9
|
"description": "Zero-dependency function performance measurement with hierarchical logging",
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
"license": "MIT",
|
|
18
19
|
"repository": {
|
|
19
20
|
"type": "git",
|
|
20
|
-
"url": "https://github.com/7flash/
|
|
21
|
+
"url": "https://github.com/7flash/measure-fn"
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
23
24
|
"@types/bun": "latest"
|
package/.github/workflows/ci.yml
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
name: CI
|
|
3
|
-
|
|
4
|
-
on:
|
|
5
|
-
push:
|
|
6
|
-
branches: [master, main]
|
|
7
|
-
pull_request:
|
|
8
|
-
|
|
9
|
-
jobs:
|
|
10
|
-
test:
|
|
11
|
-
runs-on: ubuntu-latest
|
|
12
|
-
steps:
|
|
13
|
-
- uses: actions/checkout@v4
|
|
14
|
-
- uses: oven-sh/setup-bun@v1
|
|
15
|
-
with:
|
|
16
|
-
bun-version: latest
|
|
17
|
-
|
|
18
|
-
- run: bun install
|
|
19
|
-
- run: bun test
|
|
20
|
-
- run: bun run example.ts
|