measure-fn 3.3.0 → 3.6.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 +134 -201
- package/SKILL.md +48 -27
- package/banner.png +0 -0
- package/example.ts +27 -28
- package/index.test.ts +146 -0
- package/index.ts +35 -6
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,296 +1,229 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="banner.png" alt="measure-fn" width="100%" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
```
|
|
6
|
-
[18:50:04.893] [a] ✓ Load config 0.09ms → {"env":"prod","port":3000}
|
|
7
|
-
[18:50:04.894] [b] = App ready
|
|
8
|
-
[18:50:04.895] [e] ... Parallel Fetch
|
|
9
|
-
[18:50:04.895] [e-a] ... Fetch User (userId=1)
|
|
10
|
-
[18:50:04.895] [e-b] ... Fetch User (userId=2)
|
|
11
|
-
[18:50:04.950] [e-b] ✓ Fetch User 55.58ms → {"id":2,"name":"User 2"}
|
|
12
|
-
[18:50:04.981] [e-a] ✓ Fetch User 85.93ms → {"id":1,"name":"User 1"}
|
|
13
|
-
[18:50:04.981] [e] ✓ Parallel Fetch 86.08ms
|
|
14
|
-
[18:50:05.072] [f] ✓ DB query 91.12ms → {"rows":42} ⚠ OVER BUDGET (30.00ms)
|
|
15
|
-
[18:50:05.775] [m] ... Fetch all users (20 items)
|
|
16
|
-
[18:50:06.179] [m] = 5/20 (0.4s, 12/s)
|
|
17
|
-
[18:50:07.450] [m] ✓ Fetch all users (20 items) 1.7s → "20/20 ok"
|
|
18
|
-
[18:50:07.450] [api:a] ... GET /users
|
|
19
|
-
[18:50:07.450] [db:a] ... SELECT users
|
|
20
|
-
[18:50:07.493] [db:a] ✓ SELECT users 43.07ms → [{"id":1},{"id":2}]
|
|
21
|
-
[18:50:07.493] [api:a] ✓ GET /users 43.32ms → [{"id":1},{"id":2}]
|
|
22
|
-
[18:50:08.721] [o] ✓ Slow op 1.2s → "slow"
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
No setup. No dashboards. Just wrap your functions.
|
|
26
|
-
|
|
27
|
-
## Install
|
|
5
|
+
Wrap functions with automatic try-catch, timing, timeouts, and structured logging.
|
|
28
6
|
|
|
29
7
|
```sh
|
|
30
8
|
bun add measure-fn
|
|
31
9
|
```
|
|
32
10
|
|
|
33
|
-
## Quick Start
|
|
34
|
-
|
|
35
11
|
```typescript
|
|
36
12
|
import { measure, measureSync } from 'measure-fn';
|
|
37
13
|
|
|
38
|
-
|
|
39
|
-
const config = measureSync('Parse config', () => JSON.parse(
|
|
40
|
-
// → [a] ✓ Parse config 0.20ms → {"port":3000}
|
|
41
|
-
|
|
42
|
-
// Async — start + end
|
|
43
|
-
const data = await measure('Fetch data', async () => {
|
|
44
|
-
return await fetch(url).then(r => r.json());
|
|
45
|
-
});
|
|
46
|
-
// → [b] ... Fetch data
|
|
47
|
-
// → [b] ✓ Fetch data 245.12ms → [{"id":1}]
|
|
14
|
+
const users = await measure('Fetch users', () => fetchUsers());
|
|
15
|
+
const config = measureSync('Parse config', () => JSON.parse(raw));
|
|
48
16
|
```
|
|
49
17
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
| `[id] ✓ label Nms → value` | Success | `[a] ✓ Fetch 102ms → {"id":1}` |
|
|
56
|
-
| `[id] ✗ label Nms (err)` | Error | `[a] ✗ Fetch 2ms (timeout)` |
|
|
57
|
-
| `[id] = label` | Annotation | `[a] = checkpoint` |
|
|
18
|
+
```
|
|
19
|
+
[a] ... Fetch users
|
|
20
|
+
[a] ✓ Fetch users 86ms → [{"id":1},{"id":2}]
|
|
21
|
+
[b] ✓ Parse config 0.09ms → {"env":"prod","port":3000}
|
|
22
|
+
```
|
|
58
23
|
|
|
59
|
-
|
|
24
|
+
## Defaults
|
|
60
25
|
|
|
61
|
-
|
|
26
|
+
Every `measure` call:
|
|
27
|
+
- **catches errors** → logs `✗` with stack trace, returns `null` (no unhandled rejections)
|
|
28
|
+
- **logs timing** → `✓ label Nms → result`
|
|
29
|
+
- **assigns an ID** → `[a]`, `[b]`, `[a-a]` for nested calls
|
|
62
30
|
|
|
63
|
-
##
|
|
31
|
+
## Error Handling
|
|
64
32
|
|
|
65
|
-
|
|
33
|
+
By default, errors return `null`:
|
|
66
34
|
|
|
67
35
|
```typescript
|
|
68
|
-
// Simple
|
|
69
36
|
const user = await measure('Fetch user', () => fetchUser(1));
|
|
70
|
-
|
|
71
|
-
// Nested + parallel
|
|
72
|
-
await measure('Pipeline', async (m) => {
|
|
73
|
-
await Promise.all([
|
|
74
|
-
m({ label: 'Fetch', userId: 1 }, () => fetchUser(1)),
|
|
75
|
-
m({ label: 'Fetch', userId: 2 }, () => fetchUser(2)),
|
|
76
|
-
]);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
// Annotation
|
|
80
|
-
await measure('checkpoint');
|
|
37
|
+
// throws → logs ✗, user = null
|
|
81
38
|
```
|
|
82
39
|
|
|
83
|
-
|
|
40
|
+
Pass `onError` as 3rd argument to provide a fallback:
|
|
84
41
|
|
|
85
42
|
```typescript
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
-
measureSync('Report', (m) => {
|
|
91
|
-
const data = m('Parse', () => parse(raw));
|
|
92
|
-
return m('Summarize', () => summarize(data));
|
|
93
|
-
});
|
|
43
|
+
const user = await measure('Fetch user', () => fetchUser(1),
|
|
44
|
+
(error) => defaultUser
|
|
45
|
+
);
|
|
46
|
+
// throws → logs ✗, user = defaultUser
|
|
94
47
|
```
|
|
95
48
|
|
|
96
|
-
|
|
49
|
+
If `onError` itself throws, that's also caught — returns `null`. Measure never crashes.
|
|
97
50
|
|
|
98
|
-
|
|
51
|
+
Use `.assert()` when you need a guaranteed non-null result:
|
|
99
52
|
|
|
100
53
|
```typescript
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
await getUser(2); // → [b] ... Get user → [b] ✓ Get user 75ms → {...}
|
|
54
|
+
const user = await measure.assert('Get user', () => fetchUser(1));
|
|
55
|
+
// throws → logs ✗, re-throws with .cause = original error
|
|
104
56
|
```
|
|
105
57
|
|
|
106
|
-
|
|
58
|
+
| Pattern | On error |
|
|
59
|
+
|---------|----------|
|
|
60
|
+
| `measure(label, fn)` | returns `null` |
|
|
61
|
+
| `measure(label, fn, onError)` | returns `onError(error)` |
|
|
62
|
+
| `measure.assert(label, fn)` | throws with `.cause` |
|
|
107
63
|
|
|
108
|
-
|
|
109
|
-
const results = await measure.batch('Process users', userIds, async (id) => {
|
|
110
|
-
return await processUser(id);
|
|
111
|
-
}, { every: 100 }); // log progress every 100 items
|
|
112
|
-
```
|
|
113
|
-
Output:
|
|
114
|
-
```
|
|
115
|
-
[a] ... Process users (500 items)
|
|
116
|
-
[a] = 100/500 (1.2s, 83/s)
|
|
117
|
-
[a] = 200/500 (2.1s, 95/s)
|
|
118
|
-
[a] ✓ Process users (500 items) 5.3s → "500/500 ok"
|
|
119
|
-
```
|
|
64
|
+
## Timeout
|
|
120
65
|
|
|
121
|
-
|
|
66
|
+
Set `timeout` in the label object to abort after N milliseconds:
|
|
122
67
|
|
|
123
68
|
```typescript
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
}, () => fetchFlakyApi());
|
|
127
|
-
```
|
|
128
|
-
```
|
|
129
|
-
[a] ... Flaky API [1/3]
|
|
130
|
-
[a] ✗ Flaky API [1/3] 102ms (timeout)
|
|
131
|
-
[b] ... Flaky API [2/3]
|
|
132
|
-
[b] ✓ Flaky API [2/3] 89ms → {"status":"ok"}
|
|
69
|
+
const data = await measure({ label: 'Slow API', timeout: 5000 }, () => fetchSlowApi());
|
|
70
|
+
// > 5s → ✗ Slow API 5.0s (Timeout (5.0s)), returns null
|
|
133
71
|
```
|
|
134
72
|
|
|
135
|
-
|
|
73
|
+
Works with `onError`:
|
|
136
74
|
|
|
137
75
|
```typescript
|
|
138
|
-
const
|
|
139
|
-
|
|
76
|
+
const data = await measure({ label: 'Slow API', timeout: 5000 }, () => fetchSlowApi(),
|
|
77
|
+
(error) => cachedData
|
|
78
|
+
);
|
|
140
79
|
```
|
|
141
80
|
|
|
142
|
-
|
|
81
|
+
## Budget
|
|
82
|
+
|
|
83
|
+
Set `budget` to log a warning when a call exceeds the expected time (doesn't abort):
|
|
143
84
|
|
|
144
85
|
```typescript
|
|
145
|
-
await measure({ label: 'DB query', budget: 100 },
|
|
146
|
-
return await db.query('SELECT * FROM users');
|
|
147
|
-
});
|
|
86
|
+
await measure({ label: 'DB query', budget: 100 }, () => db.query('...'));
|
|
148
87
|
// → [a] ✓ DB query 245ms → [...] ⚠ OVER BUDGET (100ms)
|
|
149
88
|
```
|
|
150
89
|
|
|
151
|
-
|
|
90
|
+
Combine both — budget warns, timeout enforces:
|
|
152
91
|
|
|
153
92
|
```typescript
|
|
154
|
-
|
|
155
|
-
const db = createMeasure('db');
|
|
156
|
-
|
|
157
|
-
await api.measure('GET /users', async () => {
|
|
158
|
-
return await db.measure('SELECT', () => query('...'));
|
|
159
|
-
});
|
|
160
|
-
// → [api:a] ... GET /users
|
|
161
|
-
// → [db:a] ✓ SELECT 44ms → [...]
|
|
162
|
-
// → [api:a] ✓ GET /users 45ms → [...]
|
|
93
|
+
await measure({ label: 'Query', budget: 100, timeout: 5000 }, () => query());
|
|
163
94
|
```
|
|
164
95
|
|
|
165
|
-
|
|
96
|
+
## Nested Calls
|
|
166
97
|
|
|
167
|
-
|
|
98
|
+
Pass a child `m` to create hierarchy:
|
|
168
99
|
|
|
169
100
|
```typescript
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
},
|
|
101
|
+
await measure('Pipeline', async (m) => {
|
|
102
|
+
const user = await m('Fetch user', () => fetchUser(1));
|
|
103
|
+
const posts = await m('Fetch posts', () => fetchPosts(user.id));
|
|
104
|
+
return posts;
|
|
185
105
|
});
|
|
186
106
|
```
|
|
187
107
|
|
|
188
|
-
|
|
108
|
+
```
|
|
109
|
+
[a] ... Pipeline
|
|
110
|
+
[a-a] ✓ Fetch user 82ms → {"id":1}
|
|
111
|
+
[a-b] ✓ Fetch posts 45ms → [...]
|
|
112
|
+
[a] ✓ Pipeline 128ms
|
|
113
|
+
```
|
|
189
114
|
|
|
190
|
-
|
|
115
|
+
Parallel:
|
|
191
116
|
|
|
192
117
|
```typescript
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
myTelemetry.track(event);
|
|
199
|
-
}
|
|
118
|
+
await measure('Load all', async (m) => {
|
|
119
|
+
const [users, posts] = await Promise.all([
|
|
120
|
+
m('Users', () => fetchUsers()),
|
|
121
|
+
m('Posts', () => fetchPosts()),
|
|
122
|
+
]);
|
|
200
123
|
});
|
|
201
124
|
```
|
|
202
125
|
|
|
203
|
-
|
|
126
|
+
## Label Object
|
|
127
|
+
|
|
128
|
+
The first argument can be a string or an object. Object fields:
|
|
204
129
|
|
|
205
|
-
|
|
130
|
+
| Field | Type | Effect |
|
|
131
|
+
|-------|------|--------|
|
|
132
|
+
| `label` | `string` | Display name (required if object) |
|
|
133
|
+
| `timeout` | `number` | Abort after N ms |
|
|
134
|
+
| `budget` | `number` | Warn if slower than N ms |
|
|
135
|
+
| any other | `any` | Logged as metadata |
|
|
206
136
|
|
|
207
137
|
```typescript
|
|
208
|
-
|
|
138
|
+
await measure({ label: 'Fetch user', userId: 1, timeout: 3000 }, () => fetchUser(1));
|
|
139
|
+
// → [a] ... Fetch user (userId=1)
|
|
209
140
|
```
|
|
210
141
|
|
|
211
|
-
|
|
142
|
+
## Extensions
|
|
212
143
|
|
|
213
|
-
|
|
214
|
-
import { safeStringify, formatDuration, resetCounter } from 'measure-fn';
|
|
144
|
+
### `measure.wrap(label, fn)` — wrap once, measure every call
|
|
215
145
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
146
|
+
```typescript
|
|
147
|
+
const getUser = measure.wrap('Get user', fetchUser);
|
|
148
|
+
await getUser(1); // → [a] ✓ Get user 82ms
|
|
149
|
+
await getUser(2); // → [b] ✓ Get user 75ms
|
|
219
150
|
```
|
|
220
151
|
|
|
221
|
-
|
|
152
|
+
### `measure.batch(label, items, fn, opts?)` — array with progress
|
|
222
153
|
|
|
223
|
-
|
|
154
|
+
```typescript
|
|
155
|
+
const results = await measure.batch('Process', userIds, async (id) => {
|
|
156
|
+
return await processUser(id);
|
|
157
|
+
}, { every: 100 });
|
|
158
|
+
// → [a] ... Process (500 items)
|
|
159
|
+
// → [a] = 100/500 (1.2s, 83/s)
|
|
160
|
+
// → [a] ✓ Process (500 items) 5.3s → "500/500 ok"
|
|
161
|
+
```
|
|
224
162
|
|
|
225
|
-
|
|
163
|
+
### `measure.retry(label, opts, fn)` — retry with backoff
|
|
226
164
|
|
|
227
|
-
|
|
165
|
+
```typescript
|
|
166
|
+
const result = await measure.retry('Flaky API', {
|
|
167
|
+
attempts: 3, delay: 1000, backoff: 2
|
|
168
|
+
}, () => fetchFlakyApi());
|
|
169
|
+
// → [a] ✗ Flaky API [1/3] 102ms (timeout)
|
|
170
|
+
// → [b] ✓ Flaky API [2/3] 89ms → {"status":"ok"}
|
|
171
|
+
```
|
|
228
172
|
|
|
229
|
-
|
|
173
|
+
### `measure.timed(label, fn?)` — get duration programmatically
|
|
230
174
|
|
|
231
175
|
```typescript
|
|
232
|
-
const
|
|
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
|
-
});
|
|
176
|
+
const { result, duration } = await measure.timed('Fetch', () => fetchUsers());
|
|
242
177
|
```
|
|
243
178
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
**When you need the result to be non-null:**
|
|
179
|
+
### `createMeasure(prefix)` — scoped instance
|
|
247
180
|
|
|
248
181
|
```typescript
|
|
249
|
-
|
|
250
|
-
const
|
|
182
|
+
const api = createMeasure('api');
|
|
183
|
+
const db = createMeasure('db');
|
|
251
184
|
|
|
252
|
-
|
|
253
|
-
|
|
185
|
+
await api.measure('GET /users', async () => {
|
|
186
|
+
return await db.measure('SELECT', () => query('...'));
|
|
187
|
+
});
|
|
188
|
+
// → [api:a] ... GET /users
|
|
189
|
+
// → [db:a] ✓ SELECT 44ms
|
|
190
|
+
// → [api:a] ✓ GET /users 45ms
|
|
254
191
|
```
|
|
255
192
|
|
|
256
|
-
|
|
193
|
+
### Annotations
|
|
257
194
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
| `measure.assert()` | logs `✗`, then throws (`.cause` = original error) | Must have non-null (Bun.serve, etc.) |
|
|
263
|
-
| `?? fallback` | returns fallback | Graceful degradation |
|
|
195
|
+
```typescript
|
|
196
|
+
await measure('Server ready');
|
|
197
|
+
// → [a] = Server ready
|
|
198
|
+
```
|
|
264
199
|
|
|
265
|
-
|
|
200
|
+
## Configure
|
|
266
201
|
|
|
267
202
|
```typescript
|
|
268
|
-
|
|
269
|
-
const response = await measure('Handle', () => handler(req)); // null = ???
|
|
203
|
+
import { configure } from 'measure-fn';
|
|
270
204
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
//
|
|
274
|
-
//
|
|
205
|
+
configure({
|
|
206
|
+
silent: true, // suppress all output
|
|
207
|
+
timestamps: true, // prepend [HH:MM:SS.mmm]
|
|
208
|
+
maxResultLength: 200, // truncate results (default: 80)
|
|
209
|
+
logger: (event) => { // custom event handler
|
|
210
|
+
myTelemetry.track(event);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
275
213
|
```
|
|
276
214
|
|
|
277
|
-
|
|
215
|
+
Env vars: `MEASURE_SILENT=1`, `MEASURE_TIMESTAMPS=1`
|
|
278
216
|
|
|
279
|
-
|
|
280
|
-
export type MeasureEvent = {
|
|
281
|
-
type: 'start' | 'success' | 'error' | 'annotation';
|
|
282
|
-
id: string; label: string; depth: number;
|
|
283
|
-
duration?: number; result?: unknown; error?: unknown;
|
|
284
|
-
meta?: Record<string, unknown>; budget?: number;
|
|
285
|
-
};
|
|
286
|
-
export type TimedResult<T> = { result: T | null; duration: number };
|
|
287
|
-
export type RetryOpts = { attempts?: number; delay?: number; backoff?: number };
|
|
288
|
-
export type BatchOpts = { every?: number };
|
|
289
|
-
```
|
|
217
|
+
## Output Format
|
|
290
218
|
|
|
291
|
-
|
|
219
|
+
| Symbol | Meaning |
|
|
220
|
+
|--------|---------|
|
|
221
|
+
| `[a] ...` | started |
|
|
222
|
+
| `[a] ✓` | success |
|
|
223
|
+
| `[a] ✗` | error |
|
|
224
|
+
| `[a] =` | annotation |
|
|
292
225
|
|
|
293
|
-
|
|
226
|
+
IDs encode hierarchy: `[a]` → root, `[a-a]` → first child, `[a-b]` → second child.
|
|
294
227
|
|
|
295
228
|
## License
|
|
296
229
|
|
package/SKILL.md
CHANGED
|
@@ -104,11 +104,19 @@ const result = await measure.retry('External API', {
|
|
|
104
104
|
}, () => callExternalService());
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
-
### 7. Budget warnings
|
|
107
|
+
### 7. Budget warnings and timeouts
|
|
108
108
|
|
|
109
109
|
```typescript
|
|
110
|
+
// Budget: warns but doesn't stop
|
|
110
111
|
await measure({ label: 'DB query', budget: 100 }, () => heavyQuery());
|
|
111
112
|
// → [a] ✓ DB query 245ms → [...] ⚠ OVER BUDGET (100ms)
|
|
113
|
+
|
|
114
|
+
// Timeout: aborts after N ms, returns null
|
|
115
|
+
await measure({ label: 'External API', timeout: 5000 }, () => fetchSlowApi());
|
|
116
|
+
// > 5s → [a] ✗ External API 5.0s (Timeout (5.0s))
|
|
117
|
+
|
|
118
|
+
// Both together: budget warns, timeout enforces
|
|
119
|
+
await measure({ label: 'Query', budget: 100, timeout: 5000 }, () => db.query('...'));
|
|
112
120
|
```
|
|
113
121
|
|
|
114
122
|
### 8. Assert non-null results
|
|
@@ -139,29 +147,52 @@ await measure('Server ready'); // → [a] = Server ready
|
|
|
139
147
|
measureSync('Config loaded'); // → [b] = Config loaded
|
|
140
148
|
```
|
|
141
149
|
|
|
142
|
-
### 11.
|
|
150
|
+
### 11. Error handling — `onError` 3rd argument
|
|
143
151
|
|
|
144
|
-
`measure
|
|
152
|
+
`measure` never throws. Pass an `onError` handler as 3rd argument to handle errors:
|
|
145
153
|
|
|
146
154
|
```typescript
|
|
147
|
-
//
|
|
155
|
+
// Default: null on error
|
|
156
|
+
const user = await measure('Fetch user', () => fetchUser(1));
|
|
157
|
+
|
|
158
|
+
// Recovery: fallback on error
|
|
159
|
+
const user = await measure('Fetch user', () => fetchUser(1),
|
|
160
|
+
(error) => defaultUser
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// Error inspection: handle known errors, rethrow unknown
|
|
164
|
+
const user = await measure('Fetch user', () => fetchUser(1),
|
|
165
|
+
(error) => {
|
|
166
|
+
if (error instanceof NetworkError) return cachedUser;
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// Bun.serve: always return a Response
|
|
148
172
|
Bun.serve({
|
|
149
|
-
fetch: (req) => measure
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
173
|
+
fetch: (req) => measure(
|
|
174
|
+
{ label: `${req.method} ${req.url}` },
|
|
175
|
+
() => handleRequest(req),
|
|
176
|
+
(error) => new Response('Internal Server Error', { status: 500 })
|
|
177
|
+
),
|
|
153
178
|
});
|
|
179
|
+
```
|
|
154
180
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
})) ?? new Response('Internal Server Error', { status: 500 });
|
|
161
|
-
},
|
|
162
|
-
});
|
|
181
|
+
`.assert()` re-throws on error with `.cause` = original error:
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
await measure.assert('Op', () => work());
|
|
185
|
+
// throws: Error('measure.assert: "Op" failed', { cause: originalError })
|
|
163
186
|
```
|
|
164
187
|
|
|
188
|
+
## Error Model
|
|
189
|
+
|
|
190
|
+
| Pattern | On error | Use when |
|
|
191
|
+
|---------|----------|----------|
|
|
192
|
+
| `measure(label, fn)` | logs `✗`, returns `null` | Default — pipeline resilience |
|
|
193
|
+
| `measure(label, fn, onError)` | logs `✗`, calls `onError(error)` | Recovery, fallbacks, error inspection |
|
|
194
|
+
| `measure.assert(label, fn)` | logs `✗`, throws with `.cause` | Must have non-null |
|
|
195
|
+
|
|
165
196
|
## Configuration
|
|
166
197
|
|
|
167
198
|
```typescript
|
|
@@ -186,16 +217,6 @@ const { result, duration } = await measure.timed('Fetch', () => fetchUsers());
|
|
|
186
217
|
if (duration > 1000) alert('Slow!');
|
|
187
218
|
```
|
|
188
219
|
|
|
189
|
-
## Error Model — measure Never Throws
|
|
190
|
-
|
|
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 |
|
|
198
|
-
|
|
199
220
|
## Anti-Patterns
|
|
200
221
|
|
|
201
222
|
```typescript
|
|
@@ -217,7 +238,7 @@ await measure('Outer', async (m) => {
|
|
|
217
238
|
|
|
218
239
|
| Export | Use |
|
|
219
240
|
|--------|-----|
|
|
220
|
-
| `measure(label, fn?)` | Async measurement |
|
|
241
|
+
| `measure(label, fn?, onError?)` | Async measurement (onError handles expected errors) |
|
|
221
242
|
| `measureSync(label, fn?)` | Sync measurement |
|
|
222
243
|
| `measure.wrap(label, fn)` | Decorator — wrap once, measure every call |
|
|
223
244
|
| `measure.batch(label, items, fn, opts?)` | Array + progress |
|
package/banner.png
ADDED
|
Binary file
|
package/example.ts
CHANGED
|
@@ -100,18 +100,28 @@ async function main() {
|
|
|
100
100
|
|
|
101
101
|
// ─── Bun.serve patterns ──────────────────────────────────────────────
|
|
102
102
|
// measure() returns T | null — on error it returns null instead of throwing.
|
|
103
|
-
//
|
|
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.
|
|
103
|
+
// Use the onError 3rd argument to provide a fallback Response.
|
|
108
104
|
|
|
109
105
|
async function bunServeExample() {
|
|
110
106
|
console.log('\n─── Bun.serve Patterns ─────────────────────────────');
|
|
111
107
|
|
|
112
|
-
// ✅ Pattern 1:
|
|
108
|
+
// ✅ Pattern 1: onError — graceful 500 fallback with error details
|
|
113
109
|
const server1 = Bun.serve({
|
|
114
|
-
port: 0,
|
|
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,
|
|
115
125
|
fetch: (req) => measure.assert('Handle request', async () => {
|
|
116
126
|
const url = new URL(req.url);
|
|
117
127
|
if (url.pathname === '/fail') throw new Error('Route error');
|
|
@@ -119,34 +129,23 @@ async function bunServeExample() {
|
|
|
119
129
|
}),
|
|
120
130
|
});
|
|
121
131
|
|
|
122
|
-
//
|
|
123
|
-
const
|
|
124
|
-
|
|
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
|
-
});
|
|
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()}`);
|
|
133
135
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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()}`);
|
|
137
142
|
|
|
138
143
|
try {
|
|
139
|
-
await fetch(`http://localhost:${
|
|
144
|
+
await fetch(`http://localhost:${server2.port}/fail`);
|
|
140
145
|
} catch {
|
|
141
146
|
console.log(` assert pattern (fail): server rejected (expected)`);
|
|
142
147
|
}
|
|
143
148
|
|
|
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
149
|
server1.stop();
|
|
151
150
|
server2.stop();
|
|
152
151
|
}
|
package/index.test.ts
CHANGED
|
@@ -427,6 +427,152 @@ describe("measure.assert", () => {
|
|
|
427
427
|
});
|
|
428
428
|
});
|
|
429
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 rethrow returns null (caught by safety net)", async () => {
|
|
467
|
+
const out = captureConsole();
|
|
468
|
+
const result = await measure('Op', async () => { throw new Error('critical'); }, (e) => { throw e; });
|
|
469
|
+
out.restore();
|
|
470
|
+
expect(result).toBeNull();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("onError can inspect error type and recover", async () => {
|
|
474
|
+
const out = captureConsole();
|
|
475
|
+
const result = await measure('Fetch', async () => {
|
|
476
|
+
throw new TypeError('invalid');
|
|
477
|
+
}, (error) => {
|
|
478
|
+
if (error instanceof TypeError) return 'recovered';
|
|
479
|
+
throw error;
|
|
480
|
+
});
|
|
481
|
+
out.restore();
|
|
482
|
+
expect(result).toBe('recovered');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("still logs error even when onError handles it", async () => {
|
|
486
|
+
const events: any[] = [];
|
|
487
|
+
configure({ logger: (e) => events.push(e) });
|
|
488
|
+
await measure('Op', async () => { throw new Error('x'); }, () => 'fallback');
|
|
489
|
+
configure({ logger: null });
|
|
490
|
+
const errorEvent = events.find(e => e.type === 'error');
|
|
491
|
+
expect(errorEvent).toBeTruthy();
|
|
492
|
+
expect(errorEvent.label).toBe('Op');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("Bun.serve pattern with onError fallback", async () => {
|
|
496
|
+
const out = captureConsole();
|
|
497
|
+
const result = await measure(
|
|
498
|
+
{ label: 'Handle request' },
|
|
499
|
+
async () => {
|
|
500
|
+
throw new Error('route error');
|
|
501
|
+
return new Response('ok');
|
|
502
|
+
},
|
|
503
|
+
(error) => new Response(`Error: ${(error as Error).message}`, { status: 500 })
|
|
504
|
+
);
|
|
505
|
+
out.restore();
|
|
506
|
+
expect(result).toBeInstanceOf(Response);
|
|
507
|
+
expect(result!.status).toBe(500);
|
|
508
|
+
expect(await result!.text()).toContain('route error');
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("if onError itself throws, returns null instead of crashing", async () => {
|
|
512
|
+
const out = captureConsole();
|
|
513
|
+
const result = await measure('Primary DB', async () => {
|
|
514
|
+
throw new Error('primary failed');
|
|
515
|
+
}, (error) => {
|
|
516
|
+
// fallback DB call also fails
|
|
517
|
+
throw new Error('backup DB also failed');
|
|
518
|
+
});
|
|
519
|
+
out.restore();
|
|
520
|
+
expect(result).toBeNull();
|
|
521
|
+
// should log both errors
|
|
522
|
+
expect(out.errors.some(l => l.includes('primary failed'))).toBe(true);
|
|
523
|
+
expect(out.errors.some(l => l.includes('backup DB also failed'))).toBe(true);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// ─── timeout ─────────────────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
describe("timeout", () => {
|
|
530
|
+
beforeEach(() => {
|
|
531
|
+
resetCounter();
|
|
532
|
+
configure({ silent: false, logger: null, timestamps: false });
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("aborts and returns null if function exceeds timeout", async () => {
|
|
536
|
+
const out = captureConsole();
|
|
537
|
+
const result = await measure(
|
|
538
|
+
{ label: 'Slow op', timeout: 50 },
|
|
539
|
+
async () => {
|
|
540
|
+
await new Promise(r => setTimeout(r, 200));
|
|
541
|
+
return 'done';
|
|
542
|
+
}
|
|
543
|
+
);
|
|
544
|
+
out.restore();
|
|
545
|
+
expect(result).toBeNull();
|
|
546
|
+
expect(out.errors.some(l => l.includes('Timeout'))).toBe(true);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("succeeds if function completes within timeout", async () => {
|
|
550
|
+
const out = captureConsole();
|
|
551
|
+
const result = await measure(
|
|
552
|
+
{ label: 'Fast op', timeout: 200 },
|
|
553
|
+
async () => {
|
|
554
|
+
await new Promise(r => setTimeout(r, 10));
|
|
555
|
+
return 'done';
|
|
556
|
+
}
|
|
557
|
+
);
|
|
558
|
+
out.restore();
|
|
559
|
+
expect(result).toBe('done');
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("timeout with onError returns fallback", async () => {
|
|
563
|
+
const out = captureConsole();
|
|
564
|
+
const result = await measure(
|
|
565
|
+
{ label: 'Slow op', timeout: 50 },
|
|
566
|
+
async () => {
|
|
567
|
+
await new Promise(r => setTimeout(r, 200));
|
|
568
|
+
return 'done';
|
|
569
|
+
},
|
|
570
|
+
(error) => 'timed-out'
|
|
571
|
+
);
|
|
572
|
+
out.restore();
|
|
573
|
+
expect(result).toBe('timed-out');
|
|
574
|
+
});
|
|
575
|
+
});
|
|
430
576
|
// ─── measure.wrap ────────────────────────────────────────────────────
|
|
431
577
|
|
|
432
578
|
describe("measure.wrap", () => {
|
package/index.ts
CHANGED
|
@@ -115,6 +115,12 @@ const extractBudget = (actionInternal: string | object): number | undefined => {
|
|
|
115
115
|
return undefined;
|
|
116
116
|
};
|
|
117
117
|
|
|
118
|
+
const extractTimeout = (actionInternal: string | object): number | undefined => {
|
|
119
|
+
if (typeof actionInternal !== 'object' || actionInternal === null) return undefined;
|
|
120
|
+
if ('timeout' in actionInternal) return Number((actionInternal as any).timeout);
|
|
121
|
+
return undefined;
|
|
122
|
+
};
|
|
123
|
+
|
|
118
124
|
const extractMeta = (actionInternal: string | object): Record<string, unknown> | undefined => {
|
|
119
125
|
if (typeof actionInternal !== 'object' || actionInternal === null) return undefined;
|
|
120
126
|
const details = { ...actionInternal };
|
|
@@ -214,16 +220,17 @@ const createNestedResolver = (
|
|
|
214
220
|
fullIdChain: string[],
|
|
215
221
|
childCounterRef: { value: number },
|
|
216
222
|
depth: number,
|
|
217
|
-
resolver: <U>(fn: any, action: any, chain: (string | number)[], depth: number) => Promise<U | null> | (U | null),
|
|
223
|
+
resolver: <U>(fn: any, action: any, chain: (string | number)[], depth: number, onError?: (error: unknown) => any) => Promise<U | null> | (U | null),
|
|
218
224
|
prefix?: string
|
|
219
225
|
) => {
|
|
220
226
|
return (...args: any[]) => {
|
|
221
227
|
const label = args[0];
|
|
222
228
|
const fn = args[1];
|
|
229
|
+
const onError = args[2];
|
|
223
230
|
|
|
224
231
|
if (typeof fn === 'function') {
|
|
225
232
|
const childParentChain = [...fullIdChain, childCounterRef.value++];
|
|
226
|
-
return resolver(fn, label, childParentChain, depth + 1);
|
|
233
|
+
return resolver(fn, label, childParentChain, depth + 1, typeof onError === 'function' ? onError : undefined);
|
|
227
234
|
} else {
|
|
228
235
|
emit({
|
|
229
236
|
type: 'annotation',
|
|
@@ -255,12 +262,14 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
255
262
|
fnInternal: (measure: MeasureFn) => Promise<U>,
|
|
256
263
|
actionInternal: string | object,
|
|
257
264
|
parentIdChain: (string | number)[],
|
|
258
|
-
depth: number
|
|
265
|
+
depth: number,
|
|
266
|
+
onError?: (error: unknown) => any
|
|
259
267
|
): Promise<U | null> => {
|
|
260
268
|
const start = performance.now();
|
|
261
269
|
const childCounterRef = { value: 0 };
|
|
262
270
|
const label = buildActionLabel(actionInternal);
|
|
263
271
|
const budget = extractBudget(actionInternal);
|
|
272
|
+
const timeout = extractTimeout(actionInternal);
|
|
264
273
|
|
|
265
274
|
const currentId = toAlpha(parentIdChain.pop() ?? 0);
|
|
266
275
|
const fullIdChain = [...parentIdChain, currentId];
|
|
@@ -277,7 +286,17 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
277
286
|
const measureForNextLevel = createNestedResolver(true, fullIdChain, childCounterRef, depth, _measureInternal, prefix);
|
|
278
287
|
|
|
279
288
|
try {
|
|
280
|
-
|
|
289
|
+
let result: U;
|
|
290
|
+
if (timeout && timeout > 0) {
|
|
291
|
+
result = await Promise.race([
|
|
292
|
+
fnInternal(measureForNextLevel as MeasureFn),
|
|
293
|
+
new Promise<never>((_, reject) =>
|
|
294
|
+
setTimeout(() => reject(new Error(`Timeout (${formatDuration(timeout)})`)), timeout)
|
|
295
|
+
),
|
|
296
|
+
]);
|
|
297
|
+
} else {
|
|
298
|
+
result = await fnInternal(measureForNextLevel as MeasureFn);
|
|
299
|
+
}
|
|
281
300
|
const duration = performance.now() - start;
|
|
282
301
|
emit({ type: 'success', id: idStr, label, depth, duration, result, budget }, prefix);
|
|
283
302
|
return result;
|
|
@@ -285,6 +304,15 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
285
304
|
const duration = performance.now() - start;
|
|
286
305
|
emit({ type: 'error', id: idStr, label, depth, duration, error, budget }, prefix);
|
|
287
306
|
_lastError = error;
|
|
307
|
+
if (onError) {
|
|
308
|
+
try {
|
|
309
|
+
return onError(error);
|
|
310
|
+
} catch (onErrorError) {
|
|
311
|
+
emit({ type: 'error', id: idStr, label: `${label} (onError)`, depth, duration: performance.now() - start, error: onErrorError, budget }, prefix);
|
|
312
|
+
_lastError = onErrorError;
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
288
316
|
return null;
|
|
289
317
|
}
|
|
290
318
|
};
|
|
@@ -334,10 +362,11 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
334
362
|
|
|
335
363
|
const measureFn = async <T = null>(
|
|
336
364
|
arg1: string | object,
|
|
337
|
-
arg2?: ((measure: MeasureFn) => Promise<T>)
|
|
365
|
+
arg2?: ((measure: MeasureFn) => Promise<T>) | ((measure: MeasureFn) => T),
|
|
366
|
+
arg3?: (error: unknown) => any
|
|
338
367
|
): Promise<T | null> => {
|
|
339
368
|
if (typeof arg2 === 'function') {
|
|
340
|
-
return _measureInternal(arg2, arg1, [counter.value++], 0) as Promise<T | null>;
|
|
369
|
+
return _measureInternal(arg2 as any, arg1, [counter.value++], 0, arg3) as Promise<T | null>;
|
|
341
370
|
} else {
|
|
342
371
|
const currentId = toAlpha(counter.value++);
|
|
343
372
|
emit({
|
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.6.0",
|
|
6
7
|
"type": "module",
|
|
7
8
|
"private": false,
|
|
8
9
|
"description": "Zero-dependency function performance measurement with hierarchical logging",
|
|
@@ -29,4 +30,4 @@
|
|
|
29
30
|
"test": "bun test",
|
|
30
31
|
"example": "bun run example.ts"
|
|
31
32
|
}
|
|
32
|
-
}
|
|
33
|
+
}
|