measure-fn 3.5.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 +135 -208
- package/SKILL.md +12 -4
- package/banner.png +0 -0
- package/index.test.ts +67 -29
- package/index.ts +28 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,302 +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
|
-
|
|
222
|
-
|
|
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`:
|
|
152
|
+
### `measure.batch(label, items, fn, opts?)` — array with progress
|
|
226
153
|
|
|
227
154
|
```typescript
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
+
```
|
|
235
162
|
|
|
236
|
-
|
|
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
|
-
);
|
|
163
|
+
### `measure.retry(label, opts, fn)` — retry with backoff
|
|
244
164
|
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
);
|
|
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"}
|
|
249
171
|
```
|
|
250
172
|
|
|
251
|
-
|
|
173
|
+
### `measure.timed(label, fn?)` — get duration programmatically
|
|
252
174
|
|
|
253
175
|
```typescript
|
|
254
|
-
|
|
255
|
-
fetch: (req) => measure(
|
|
256
|
-
{ label: `${req.method} ${req.url}` },
|
|
257
|
-
() => handleRequest(req),
|
|
258
|
-
(error) => new Response(`Error: ${error.message}`, { status: 500 })
|
|
259
|
-
),
|
|
260
|
-
});
|
|
176
|
+
const { result, duration } = await measure.timed('Fetch', () => fetchUsers());
|
|
261
177
|
```
|
|
262
178
|
|
|
263
|
-
|
|
179
|
+
### `createMeasure(prefix)` — scoped instance
|
|
264
180
|
|
|
265
181
|
```typescript
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
await measure('Op', () => work(), (e) => { throw e });
|
|
182
|
+
const api = createMeasure('api');
|
|
183
|
+
const db = createMeasure('db');
|
|
269
184
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
273
191
|
```
|
|
274
192
|
|
|
275
|
-
|
|
193
|
+
### Annotations
|
|
276
194
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
| `measure.assert(label, fn)` | logs `✗`, throws with `.cause` | Must have non-null |
|
|
195
|
+
```typescript
|
|
196
|
+
await measure('Server ready');
|
|
197
|
+
// → [a] = Server ready
|
|
198
|
+
```
|
|
282
199
|
|
|
283
|
-
##
|
|
200
|
+
## Configure
|
|
284
201
|
|
|
285
202
|
```typescript
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
203
|
+
import { configure } from 'measure-fn';
|
|
204
|
+
|
|
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
|
+
});
|
|
295
213
|
```
|
|
296
214
|
|
|
297
|
-
|
|
215
|
+
Env vars: `MEASURE_SILENT=1`, `MEASURE_TIMESTAMPS=1`
|
|
216
|
+
|
|
217
|
+
## Output Format
|
|
218
|
+
|
|
219
|
+
| Symbol | Meaning |
|
|
220
|
+
|--------|---------|
|
|
221
|
+
| `[a] ...` | started |
|
|
222
|
+
| `[a] ✓` | success |
|
|
223
|
+
| `[a] ✗` | error |
|
|
224
|
+
| `[a] =` | annotation |
|
|
298
225
|
|
|
299
|
-
|
|
226
|
+
IDs encode hierarchy: `[a]` → root, `[a-a]` → first child, `[a-b]` → second child.
|
|
300
227
|
|
|
301
228
|
## License
|
|
302
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
|
|
@@ -170,11 +178,11 @@ Bun.serve({
|
|
|
170
178
|
});
|
|
171
179
|
```
|
|
172
180
|
|
|
173
|
-
`.assert()`
|
|
181
|
+
`.assert()` re-throws on error with `.cause` = original error:
|
|
174
182
|
|
|
175
183
|
```typescript
|
|
176
184
|
await measure.assert('Op', () => work());
|
|
177
|
-
//
|
|
185
|
+
// throws: Error('measure.assert: "Op" failed', { cause: originalError })
|
|
178
186
|
```
|
|
179
187
|
|
|
180
188
|
## Error Model
|
|
@@ -230,7 +238,7 @@ await measure('Outer', async (m) => {
|
|
|
230
238
|
|
|
231
239
|
| Export | Use |
|
|
232
240
|
|--------|-----|
|
|
233
|
-
| `measure(label, fn?)` | Async measurement |
|
|
241
|
+
| `measure(label, fn?, onError?)` | Async measurement (onError handles expected errors) |
|
|
234
242
|
| `measureSync(label, fn?)` | Sync measurement |
|
|
235
243
|
| `measure.wrap(label, fn)` | Decorator — wrap once, measure every call |
|
|
236
244
|
| `measure.batch(label, items, fn, opts?)` | Array + progress |
|
package/banner.png
ADDED
|
Binary file
|
package/index.test.ts
CHANGED
|
@@ -463,16 +463,11 @@ describe("onError (3rd argument)", () => {
|
|
|
463
463
|
expect(captured).toBe(original);
|
|
464
464
|
});
|
|
465
465
|
|
|
466
|
-
test("onError
|
|
466
|
+
test("onError rethrow returns null (caught by safety net)", async () => {
|
|
467
467
|
const out = captureConsole();
|
|
468
|
-
const
|
|
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
|
-
}
|
|
468
|
+
const result = await measure('Op', async () => { throw new Error('critical'); }, (e) => { throw e; });
|
|
475
469
|
out.restore();
|
|
470
|
+
expect(result).toBeNull();
|
|
476
471
|
});
|
|
477
472
|
|
|
478
473
|
test("onError can inspect error type and recover", async () => {
|
|
@@ -487,27 +482,6 @@ describe("onError (3rd argument)", () => {
|
|
|
487
482
|
expect(result).toBe('recovered');
|
|
488
483
|
});
|
|
489
484
|
|
|
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
485
|
test("still logs error even when onError handles it", async () => {
|
|
512
486
|
const events: any[] = [];
|
|
513
487
|
configure({ logger: (e) => events.push(e) });
|
|
@@ -533,8 +507,72 @@ describe("onError (3rd argument)", () => {
|
|
|
533
507
|
expect(result!.status).toBe(500);
|
|
534
508
|
expect(await result!.text()).toContain('route error');
|
|
535
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
|
+
});
|
|
536
525
|
});
|
|
537
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
|
+
});
|
|
538
576
|
// ─── measure.wrap ────────────────────────────────────────────────────
|
|
539
577
|
|
|
540
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 };
|
|
@@ -263,6 +269,7 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
263
269
|
const childCounterRef = { value: 0 };
|
|
264
270
|
const label = buildActionLabel(actionInternal);
|
|
265
271
|
const budget = extractBudget(actionInternal);
|
|
272
|
+
const timeout = extractTimeout(actionInternal);
|
|
266
273
|
|
|
267
274
|
const currentId = toAlpha(parentIdChain.pop() ?? 0);
|
|
268
275
|
const fullIdChain = [...parentIdChain, currentId];
|
|
@@ -279,7 +286,17 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
279
286
|
const measureForNextLevel = createNestedResolver(true, fullIdChain, childCounterRef, depth, _measureInternal, prefix);
|
|
280
287
|
|
|
281
288
|
try {
|
|
282
|
-
|
|
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
|
+
}
|
|
283
300
|
const duration = performance.now() - start;
|
|
284
301
|
emit({ type: 'success', id: idStr, label, depth, duration, result, budget }, prefix);
|
|
285
302
|
return result;
|
|
@@ -287,7 +304,15 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
287
304
|
const duration = performance.now() - start;
|
|
288
305
|
emit({ type: 'error', id: idStr, label, depth, duration, error, budget }, prefix);
|
|
289
306
|
_lastError = error;
|
|
290
|
-
if (onError)
|
|
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
|
+
}
|
|
291
316
|
return null;
|
|
292
317
|
}
|
|
293
318
|
};
|
|
@@ -296,8 +321,7 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
296
321
|
fnInternal: (measure: MeasureSyncFn) => U,
|
|
297
322
|
actionInternal: string | object,
|
|
298
323
|
parentIdChain: (string | number)[],
|
|
299
|
-
depth: number
|
|
300
|
-
onError?: (error: unknown) => any
|
|
324
|
+
depth: number
|
|
301
325
|
): U | null => {
|
|
302
326
|
const start = performance.now();
|
|
303
327
|
const childCounterRef = { value: 0 };
|
|
@@ -330,7 +354,6 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
330
354
|
const duration = performance.now() - start;
|
|
331
355
|
emit({ type: 'error', id: idStr, label, depth, duration, error, budget }, prefix);
|
|
332
356
|
_lastError = error;
|
|
333
|
-
if (onError) return onError(error);
|
|
334
357
|
return null;
|
|
335
358
|
}
|
|
336
359
|
};
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"main": "./index.ts",
|
|
5
5
|
"types": "./index.ts",
|
|
6
|
-
"version": "3.
|
|
6
|
+
"version": "3.6.0",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"private": false,
|
|
9
9
|
"description": "Zero-dependency function performance measurement with hierarchical logging",
|
|
@@ -30,4 +30,4 @@
|
|
|
30
30
|
"test": "bun test",
|
|
31
31
|
"example": "bun run example.ts"
|
|
32
32
|
}
|
|
33
|
-
}
|
|
33
|
+
}
|